From a84b9484528dff2d7b39cb8297bd6a679bcd6331 Mon Sep 17 00:00:00 2001 From: pushpak1300 <31663512+pushpak1300@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:39:32 +0000 Subject: [PATCH 1/2] Update CHANGELOG --- CHANGELOG.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ffb19e1e..a62882f55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,29 @@ # Release Notes -## [Unreleased](https://github.com/laravel/ai/compare/v0.9.0...0.x) +## [Unreleased](https://github.com/laravel/ai/compare/v0.9.1...0.x) + +## [v0.9.1](https://github.com/laravel/ai/compare/v0.9.0...v0.9.1) - 2026-07-14 + +### What's Changed + +* Remove dead dall-e-3 image integration dataset by [@mrdzen](https://github.com/mrdzen) in https://github.com/laravel/ai/pull/779 +* Forward attachment provider options to OpenAI text generation requests by [@mrdzen](https://github.com/mrdzen) in https://github.com/laravel/ai/pull/768 +* Add tool choice support for Gemini, OpenAI, and Anthropic by [@JVillator0](https://github.com/JVillator0) in https://github.com/laravel/ai/pull/780 +* Add tests for the image portrait and custom size helpers by [@dfinchenko](https://github.com/dfinchenko) in https://github.com/laravel/ai/pull/783 +* Add interactive agent console command for the workbench by [@pushpak1300](https://github.com/pushpak1300) in https://github.com/laravel/ai/pull/786 +* Add web fetch tool coverage for the Anthropic provider by [@dfinchenko](https://github.com/dfinchenko) in https://github.com/laravel/ai/pull/792 +* [0.x] Validate blank base64 input consistently across file classes by [@Button99](https://github.com/Button99) in https://github.com/laravel/ai/pull/788 +* Add coverage for iterating the embeddings response by [@dfinchenko](https://github.com/dfinchenko) in https://github.com/laravel/ai/pull/790 +* Add web search user location coverage for the Anthropic provider by [@dfinchenko](https://github.com/dfinchenko) in https://github.com/laravel/ai/pull/791 +* Fix`TextDelta::combine` glues multi-step streamed text together mid-sentence by [@crishoj](https://github.com/crishoj) in https://github.com/laravel/ai/pull/789 +* Fix empty Bedrock stream content blocks by [@iAmKevinMcKee](https://github.com/iAmKevinMcKee) in https://github.com/laravel/ai/pull/785 + +### New Contributors + +* [@crishoj](https://github.com/crishoj) made their first contribution in https://github.com/laravel/ai/pull/789 +* [@iAmKevinMcKee](https://github.com/iAmKevinMcKee) made their first contribution in https://github.com/laravel/ai/pull/785 + +**Full Changelog**: https://github.com/laravel/ai/compare/v0.9.0...v0.9.1 ## [v0.9.0](https://github.com/laravel/ai/compare/v0.8.1...v0.9.0) - 2026-07-07 From 6ca3e1b8199a9ba9434a7ec1951fa3394b78be3d Mon Sep 17 00:00:00 2001 From: Pushpak Chhajed Date: Wed, 15 Jul 2026 13:45:53 +0530 Subject: [PATCH 2/2] Add Rector and Laravel Rector, apply automated refactors (#784) * Add Rector and Laravel Rector, apply automated refactors Mirrors laravel/boost's Rector config (prepared sets on php81) plus Laravel Rector via composer-based set detection, and applies the resulting refactors across src and tests. A handful of rules are skipped because they altered public API or behavior on this codebase (constructor param rename, over-narrow closure typing on tool lists, removal of generator-draining loops). declare(strict_types=1) is skipped for now to keep the diff focused. * Formatting --- composer.json | 4 +- rector.php | 50 +++++++ src/AiManager.php | 16 +- src/AiServiceProvider.php | 12 +- src/Concerns/InteractsWithFakeAgents.php | 12 +- src/Concerns/InteractsWithFakeAudio.php | 16 +- src/Concerns/InteractsWithFakeEmbeddings.php | 16 +- src/Concerns/InteractsWithFakeFiles.php | 20 +-- src/Concerns/InteractsWithFakeImages.php | 16 +- src/Concerns/InteractsWithFakeReranking.php | 8 +- src/Concerns/InteractsWithFakeStores.php | 62 +++----- .../InteractsWithFakeTranscriptions.php | 16 +- src/Console/Commands/ChatCommand.php | 23 +-- src/Console/Commands/MakeAgentCommand.php | 12 +- .../Commands/MakeAgentMiddlewareCommand.php | 11 +- src/Console/Commands/MakeToolCommand.php | 11 +- src/Embeddings.php | 6 +- src/FakePendingDispatch.php | 34 ++--- src/Files/Base64Audio.php | 1 + src/Files/Base64Document.php | 2 + src/Files/Base64Image.php | 1 + src/Files/LocalAudio.php | 2 + src/Files/LocalDocument.php | 2 + src/Files/LocalImage.php | 2 + src/Files/RemoteAudio.php | 3 +- src/Files/RemoteDocument.php | 3 +- src/Files/RemoteImage.php | 3 +- src/Files/S3Document.php | 2 + src/Files/StoredAudio.php | 2 + src/Files/StoredDocument.php | 2 + src/Files/StoredImage.php | 2 + .../Anthropic/Concerns/BuildsTextRequests.php | 2 +- .../Concerns/HandlesTextStreaming.php | 13 +- .../Anthropic/Concerns/MapsAttachments.php | 21 +-- .../Anthropic/Concerns/MapsMessages.php | 4 +- src/Gateway/Anthropic/Concerns/MapsTools.php | 2 +- .../Concerns/ParsesTextResponses.php | 10 +- .../AzureOpenAi/AzureOpenAiFileGateway.php | 2 + .../AzureOpenAi/AzureOpenAiGateway.php | 2 +- src/Gateway/Bedrock/BedrockImageGateway.php | 8 +- src/Gateway/Bedrock/BedrockTextGateway.php | 56 +++---- .../Bedrock/Concerns/MapsAttachments.php | 46 +++--- src/Gateway/CohereGateway.php | 2 +- .../Concerns/DecodesStructuredOutput.php | 2 +- .../Concerns/HandlesFailoverErrors.php | 20 +-- src/Gateway/Concerns/InvokesTools.php | 6 +- .../Concerns/ParsesServerSentEvents.php | 6 +- .../DeepSeek/Concerns/BuildsTextRequests.php | 5 +- .../Concerns/HandlesTextStreaming.php | 2 +- .../DeepSeek/Concerns/MapsAttachments.php | 9 +- .../DeepSeek/Concerns/ParsesTextResponses.php | 2 +- src/Gateway/ElevenLabsGateway.php | 2 +- src/Gateway/FakeAudioGateway.php | 2 +- src/Gateway/FakeEmbeddingGateway.php | 4 +- src/Gateway/FakeFileGateway.php | 2 +- src/Gateway/FakeImageGateway.php | 2 +- src/Gateway/FakeRerankingGateway.php | 2 +- src/Gateway/FakeStoreGateway.php | 2 +- src/Gateway/FakeTextGateway.php | 6 +- src/Gateway/FakeTranscriptionGateway.php | 2 +- .../Gemini/Concerns/BuildsTextRequests.php | 4 +- .../Gemini/Concerns/MapsAttachments.php | 12 +- .../Gemini/Concerns/ParsesTextResponses.php | 8 +- src/Gateway/Gemini/GeminiGateway.php | 10 +- src/Gateway/Gemini/GeminiStoreGateway.php | 14 +- .../Groq/Concerns/BuildsTextRequests.php | 5 +- .../Groq/Concerns/HandlesTextStreaming.php | 2 +- src/Gateway/Groq/Concerns/MapsAttachments.php | 9 +- .../Groq/Concerns/ParsesTextResponses.php | 2 +- src/Gateway/JinaGateway.php | 4 +- .../Mistral/Concerns/BuildsTextRequests.php | 5 +- .../Mistral/Concerns/MapsAttachments.php | 11 +- .../Mistral/Concerns/ParsesTextResponses.php | 2 +- src/Gateway/Mistral/MistralGateway.php | 2 +- .../Ollama/Concerns/CreatesOllamaClient.php | 2 +- .../Ollama/Concerns/HandlesTextStreaming.php | 4 +- .../Ollama/Concerns/MapsAttachments.php | 9 +- .../Ollama/Concerns/ParsesTextResponses.php | 2 +- .../OpenAi/Concerns/BuildsTextRequests.php | 4 +- .../OpenAi/Concerns/HandlesTextGeneration.php | 5 +- .../OpenAi/Concerns/MapsAttachments.php | 13 +- src/Gateway/OpenAi/OpenAiGateway.php | 8 +- src/Gateway/OpenAi/OpenAiStoreGateway.php | 2 +- .../Concerns/BuildsTextRequests.php | 5 +- .../CreatesOpenAiCompatibleClient.php | 2 +- .../Concerns/HandlesTextStreaming.php | 2 +- .../Concerns/MapsAttachments.php | 9 +- .../Concerns/ParsesTextResponses.php | 2 +- .../Concerns/BuildsTextRequests.php | 5 +- .../OpenRouter/Concerns/MapsAttachments.php | 13 +- .../Concerns/ParsesTextResponses.php | 2 +- src/Gateway/OpenRouter/OpenRouterGateway.php | 4 +- src/Gateway/StepResponse.php | 2 +- src/Gateway/TextGenerationLoop.php | 12 +- src/Gateway/TextGenerationOptions.php | 7 +- src/Gateway/VoyageAi/VoyageAiGateway.php | 2 +- .../Xai/Concerns/BuildsTextRequests.php | 4 +- .../Xai/Concerns/HandlesTextStreaming.php | 5 +- src/Gateway/Xai/Concerns/MapsAttachments.php | 13 +- src/Gateway/Xai/Concerns/MapsMessages.php | 2 +- src/Jobs/BroadcastAgent.php | 7 +- src/Jobs/GenerateAudio.php | 6 +- src/Jobs/GenerateEmbeddings.php | 6 +- src/Jobs/GenerateImage.php | 6 +- src/Jobs/GenerateTranscription.php | 6 +- src/Jobs/InvokeAgent.php | 6 +- src/Middleware/RememberConversation.php | 3 +- src/Migrations/AiMigration.php | 1 + src/Models/Conversation.php | 11 +- src/Models/ConversationMessage.php | 11 +- src/ObjectSchema.php | 1 + .../PendingEmbeddingsGeneration.php | 13 +- .../PendingTranscriptionGeneration.php | 3 +- src/Promptable.php | 16 +- src/Providers/AnthropicProvider.php | 12 +- src/Providers/AzureOpenAiProvider.php | 2 + src/Providers/BedrockProvider.php | 2 + src/Providers/Concerns/GeneratesAudio.php | 2 +- src/Providers/Concerns/GeneratesImages.php | 2 +- src/Providers/Concerns/GeneratesText.php | 7 +- .../Concerns/GeneratesTranscriptions.php | 2 +- src/Providers/Concerns/ManagesFiles.php | 2 +- src/Providers/Concerns/ManagesStores.php | 6 +- src/Providers/Concerns/StreamsText.php | 4 +- src/Providers/GeminiProvider.php | 10 +- src/Providers/OllamaProvider.php | 1 + src/Providers/OpenAiCompatibleProvider.php | 1 + src/Providers/OpenAiProvider.php | 2 +- src/Providers/Provider.php | 10 +- src/Providers/Tools/FileSearch.php | 10 +- src/Providers/Tools/WebSearch.php | 6 +- src/Responses/AudioResponse.php | 4 +- src/Responses/Data/GeneratedImage.php | 4 +- src/Responses/Data/StructuredStep.php | 2 + src/Responses/ImageResponse.php | 2 +- src/Responses/StreamableAgentResponse.php | 10 +- src/Responses/StructuredAgentResponse.php | 3 +- src/Responses/StructuredTextResponse.php | 3 +- src/Responses/TextResponse.php | 4 +- src/Responses/TranscriptionResponse.php | 2 +- src/Schema/SchemaNormalizer.php | 30 ++-- src/Storage/DatabaseConversationStore.php | 8 +- src/Store.php | 4 +- src/Streaming/Events/StreamEnd.php | 2 +- src/Streaming/Events/StreamEvent.php | 8 +- src/StructuredAnonymousAgent.php | 5 +- src/Tools/AgentTool.php | 4 +- src/Tools/Filesystem/CopyFile.php | 4 +- src/Tools/Filesystem/GetFileUrl.php | 6 +- src/Tools/McpServerTool.php | 12 +- src/Tools/McpTool.php | 4 +- src/Tools/Request.php | 4 +- src/Tools/SimilaritySearch.php | 6 +- tests/Expectations.php | 6 +- tests/Feature/AgentAttributeTest.php | 18 +-- tests/Feature/AgentFakeTest.php | 140 ++++++++---------- tests/Feature/AgentMiddlewareTest.php | 38 +++-- tests/Feature/AgentProviderOptionsTest.php | 18 +-- tests/Feature/AgentStreamFailoverTest.php | 48 +++--- tests/Feature/AiManagerDefaultConfigTest.php | 11 +- tests/Feature/AiManagerTest.php | 11 +- tests/Feature/AudioFakeTest.php | 110 ++++++-------- tests/Feature/BroadcastAgentTest.php | 34 ++--- .../Feature/Console/MakeAgentCommandTest.php | 6 +- .../MakeAgentMiddlewareCommandTest.php | 4 +- tests/Feature/Console/MakeToolCommandTest.php | 4 +- .../Feature/ConversationRelationshipTest.php | 14 +- tests/Feature/EmbeddingsCacheTest.php | 14 +- tests/Feature/EmbeddingsFakeTest.php | 116 +++++++-------- .../Feature/EmbeddingsProviderOptionsTest.php | 34 ++--- tests/Feature/FakeJsonSchemaDataTest.php | 2 +- tests/Feature/FileFakeTest.php | 76 +++++----- tests/Feature/FileFromArrayTest.php | 38 ++--- tests/Feature/FileProviderOptionsTest.php | 10 +- tests/Feature/FileToArrayTest.php | 20 +-- tests/Feature/FilesystemToolsTest.php | 76 +++++----- tests/Feature/ImageFakeTest.php | 92 +++++------- tests/Feature/McpServerToolTest.php | 2 +- tests/Feature/McpToolTest.php | 4 +- .../Providers/Anthropic/AgentFakeTest.php | 14 +- .../Providers/Anthropic/BaseUrlTest.php | 12 +- .../Providers/Anthropic/ErrorHandlingTest.php | 10 +- .../Providers/Anthropic/FileGatewayTest.php | 14 +- .../Anthropic/MessageMappingTest.php | 62 ++++---- .../Anthropic/ProviderOptionsTest.php | 12 +- .../Anthropic/RequestMappingTest.php | 103 ++++++------- .../Providers/Anthropic/StreamingTest.php | 44 +++--- .../Providers/Anthropic/ToolCallLoopTest.php | 14 +- .../Providers/Anthropic/ToolMappingTest.php | 22 +-- .../Providers/AzureOpenAi/AgentFakeTest.php | 14 +- .../Providers/AzureOpenAi/BaseUrlTest.php | 18 +-- .../Providers/AzureOpenAi/EmbeddingTest.php | 38 ++--- .../AzureOpenAi/ErrorHandlingTest.php | 10 +- .../Providers/AzureOpenAi/FileGatewayTest.php | 12 +- .../AzureOpenAi/ImageGenerationTest.php | 48 +++--- .../AzureOpenAi/MessageMappingTest.php | 36 ++--- .../AzureOpenAi/ProviderOptionsTest.php | 16 +- .../AzureOpenAi/RequestMappingTest.php | 64 ++++---- .../AzureOpenAi/StoreGatewayTest.php | 14 +- .../Providers/AzureOpenAi/StreamingTest.php | 20 +-- .../AzureOpenAi/ToolCallLoopTest.php | 10 +- .../Providers/AzureOpenAi/ToolMappingTest.php | 14 +- .../Providers/Bedrock/BedrockHelpers.php | 6 +- .../Providers/Bedrock/ReasoningTest.php | 6 +- .../Providers/Bedrock/StreamingTest.php | 12 +- .../Providers/Bedrock/ToolCallLoopTest.php | 19 +-- .../Feature/Providers/Cohere/BaseUrlTest.php | 12 +- .../Providers/Cohere/EmbeddingTest.php | 26 ++-- .../Providers/Cohere/ErrorHandlingTest.php | 14 +- .../Providers/Cohere/RerankingTest.php | 26 ++-- .../Providers/DeepSeek/AgentFakeTest.php | 14 +- .../Providers/DeepSeek/BaseUrlTest.php | 8 +- .../Providers/DeepSeek/DeepSeekHelpers.php | 10 +- .../Providers/DeepSeek/ErrorHandlingTest.php | 12 +- .../Providers/DeepSeek/MessageMappingTest.php | 24 +-- .../DeepSeek/ProviderOptionsTest.php | 16 +- .../Providers/DeepSeek/ReasoningTest.php | 16 +- .../Providers/DeepSeek/RequestMappingTest.php | 98 ++++++------ .../Providers/DeepSeek/StreamingTest.php | 22 +-- .../Providers/DeepSeek/ToolCallLoopTest.php | 15 +- .../Providers/DeepSeek/ToolMappingTest.php | 14 +- .../Providers/ElevenLabs/AudioTest.php | 28 ++-- .../Providers/ElevenLabs/BaseUrlTest.php | 12 +- .../ElevenLabs/ErrorHandlingTest.php | 14 +- .../ElevenLabs/GatewayCachingTest.php | 6 +- .../ElevenLabs/TranscriptionTest.php | 34 ++--- .../Providers/Gemini/AgentFakeTest.php | 14 +- tests/Feature/Providers/Gemini/AudioTest.php | 26 ++-- .../Feature/Providers/Gemini/BaseUrlTest.php | 12 +- .../Providers/Gemini/EmbeddingTest.php | 46 +++--- .../Providers/Gemini/ErrorHandlingTest.php | 8 +- .../Providers/Gemini/FileGatewayTest.php | 60 +++----- .../Providers/Gemini/ImageGenerationTest.php | 56 ++++--- .../Providers/Gemini/MessageMappingTest.php | 28 ++-- .../Providers/Gemini/ProviderOptionsTest.php | 14 +- .../Providers/Gemini/RequestMappingTest.php | 100 ++++++------- .../Providers/Gemini/StoreGatewayTest.php | 90 +++++------ .../Providers/Gemini/StreamingTest.php | 42 +++--- .../Providers/Gemini/ToolCallLoopTest.php | 19 +-- .../Providers/Gemini/ToolMappingTest.php | 42 +++--- .../Providers/Gemini/TranscriptionTest.php | 42 +++--- .../Feature/Providers/Groq/AgentFakeTest.php | 14 +- tests/Feature/Providers/Groq/BaseUrlTest.php | 8 +- .../Providers/Groq/ErrorHandlingTest.php | 10 +- tests/Feature/Providers/Groq/GroqHelpers.php | 6 +- .../Providers/Groq/MessageMappingTest.php | 32 ++-- .../Providers/Groq/ProviderOptionsTest.php | 16 +- .../Providers/Groq/RequestMappingTest.php | 92 ++++++------ .../Feature/Providers/Groq/StreamingTest.php | 26 ++-- .../Providers/Groq/ToolCallLoopTest.php | 15 +- .../Providers/Groq/ToolMappingTest.php | 18 +-- tests/Feature/Providers/Jina/BaseUrlTest.php | 12 +- .../Feature/Providers/Jina/EmbeddingTest.php | 26 ++-- .../Providers/Jina/ErrorHandlingTest.php | 14 +- .../Feature/Providers/Jina/RerankingTest.php | 26 ++-- .../Providers/Mistral/AgentFakeTest.php | 14 +- .../Feature/Providers/Mistral/BaseUrlTest.php | 8 +- .../Providers/Mistral/EmbeddingTest.php | 26 ++-- .../Providers/Mistral/ErrorHandlingTest.php | 10 +- .../Providers/Mistral/MessageMappingTest.php | 38 ++--- .../Providers/Mistral/ProviderOptionsTest.php | 16 +- .../Providers/Mistral/RequestMappingTest.php | 76 +++++----- .../Providers/Mistral/StreamingTest.php | 22 +-- .../Providers/Mistral/ToolCallLoopTest.php | 19 +-- .../Providers/Mistral/ToolMappingTest.php | 20 +-- .../Providers/Mistral/TranscriptionTest.php | 54 +++---- .../Providers/Ollama/AgentFakeTest.php | 14 +- .../Feature/Providers/Ollama/BaseUrlTest.php | 10 +- .../Providers/Ollama/EmbeddingTest.php | 32 ++-- .../Providers/Ollama/ErrorHandlingTest.php | 10 +- .../Providers/Ollama/MessageMappingTest.php | 24 +-- .../Providers/Ollama/OllamaHelpers.php | 2 +- .../Providers/Ollama/ProviderOptionsTest.php | 28 ++-- .../Providers/Ollama/RequestMappingTest.php | 64 ++++---- .../Providers/Ollama/StreamingTest.php | 36 ++--- .../Providers/Ollama/ToolCallLoopTest.php | 33 +++-- .../Providers/Ollama/ToolMappingTest.php | 18 +-- .../Providers/OpenAi/AgentFakeTest.php | 14 +- tests/Feature/Providers/OpenAi/AudioTest.php | 42 +++--- .../Feature/Providers/OpenAi/BaseUrlTest.php | 58 ++++---- .../Providers/OpenAi/EmbeddingTest.php | 34 ++--- .../OpenAi/EncryptedReasoningTest.php | 62 ++++---- .../Providers/OpenAi/ErrorHandlingTest.php | 12 +- .../Providers/OpenAi/FileGatewayTest.php | 30 ++-- .../Providers/OpenAi/ImageGenerationTest.php | 46 +++--- .../Providers/OpenAi/MessageMappingTest.php | 100 ++++++------- .../Providers/OpenAi/ProviderOptionsTest.php | 16 +- .../Providers/OpenAi/RequestMappingTest.php | 92 +++++------- .../Providers/OpenAi/StoreGatewayTest.php | 78 ++++------ .../Providers/OpenAi/StreamingTest.php | 24 +-- .../Providers/OpenAi/ToolCallLoopTest.php | 16 +- .../Providers/OpenAi/ToolMappingTest.php | 64 ++++---- .../Providers/OpenAi/TranscriptionTest.php | 60 +++----- .../OpenAiCompatible/OpenAiCompatibleTest.php | 87 +++++------ .../Providers/OpenRouter/AgentFakeTest.php | 14 +- .../Providers/OpenRouter/AudioTest.php | 78 ++++------ .../Providers/OpenRouter/BaseUrlTest.php | 8 +- .../Providers/OpenRouter/EmbeddingsTest.php | 24 +-- .../OpenRouter/ErrorHandlingTest.php | 12 +- .../OpenRouter/ImageGenerationTest.php | 48 +++--- .../OpenRouter/MessageMappingTest.php | 26 ++-- .../OpenRouter/ProviderOptionsTest.php | 20 +-- .../OpenRouter/RequestMappingTest.php | 88 ++++++----- .../Providers/OpenRouter/StreamingTest.php | 42 +++--- .../Providers/OpenRouter/ToolCallLoopTest.php | 19 +-- .../Providers/OpenRouter/ToolMappingTest.php | 39 ++--- .../OpenRouter/TranscriptionTest.php | 65 ++++---- .../TextGenerationOptionsInToolLoopTest.php | 16 +- .../Providers/TimeoutInToolLoopTest.php | 20 ++- .../Providers/VoyageAi/BaseUrlTest.php | 12 +- .../Providers/VoyageAi/EmbeddingTest.php | 30 ++-- .../Providers/VoyageAi/ErrorHandlingTest.php | 14 +- .../Providers/VoyageAi/RerankingTest.php | 20 +-- tests/Feature/Providers/Xai/AgentFakeTest.php | 14 +- tests/Feature/Providers/Xai/BaseUrlTest.php | 10 +- .../Providers/Xai/ErrorHandlingTest.php | 12 +- .../Providers/Xai/ImageGenerationTest.php | 28 ++-- .../Providers/Xai/MessageMappingTest.php | 58 ++++---- .../Providers/Xai/ProviderOptionsTest.php | 16 +- .../Providers/Xai/RequestMappingTest.php | 86 +++++------ tests/Feature/Providers/Xai/StreamingTest.php | 20 +-- .../Providers/Xai/ToolCallLoopTest.php | 12 +- .../Feature/Providers/Xai/ToolMappingTest.php | 18 +-- tests/Feature/RerankingFakeTest.php | 72 ++++----- tests/Feature/SimilaritySearchTest.php | 16 +- .../Storage/DatabaseConversationStoreTest.php | 49 +++--- tests/Feature/StoreFakeTest.php | 74 ++++----- tests/Feature/SubAgentTest.php | 26 ++-- tests/Feature/TranscriptionFakeTest.php | 82 +++++----- .../TranscriptionProviderOptionsTest.php | 30 ++-- tests/Feature/WithoutBroadcastingTest.php | 28 ++-- .../Agents/IncompatibleMethodsAgent.php | 5 - .../Fixtures/Agents/RememberingToolAgent.php | 3 +- .../InMemoryConversationStore.php | 2 +- tests/Fixtures/Mcp/FakeArrayMcpServerTool.php | 1 + .../Mcp/FakeErroringMcpServerTool.php | 1 + tests/Fixtures/Mcp/FakeMcpServerTool.php | 1 + tests/Fixtures/Mcp/FakeMcpTool.php | 1 + .../Mcp/FakeStreamingMcpServerTool.php | 1 + .../Mcp/FakeStructuredMcpServerTool.php | 1 + tests/Helpers.php | 6 +- tests/Integration/AgentFakeIsolationTest.php | 6 +- tests/Integration/AgentReasoningTest.php | 10 +- tests/Integration/AgentTest.php | 66 ++++----- tests/Integration/AiProviderEnumTest.php | 20 +-- tests/Integration/AudioTest.php | 12 +- tests/Integration/EmbeddingsTest.php | 16 +- tests/Integration/FileTest.php | 14 +- tests/Integration/ImageTest.php | 4 +- tests/Integration/McpClientTest.php | 4 +- tests/Integration/MultiAgentTest.php | 24 ++- tests/Integration/RerankingTest.php | 12 +- tests/Integration/StoreTest.php | 19 +-- tests/Unit/Attributes/StrictTest.php | 6 +- tests/Unit/Files/AudioPathTest.php | 8 +- tests/Unit/Files/Base64AudioTest.php | 2 +- tests/Unit/Files/DocumentPathTest.php | 8 +- tests/Unit/Files/ImagePathTest.php | 8 +- tests/Unit/Files/RemoteUrlTest.php | 12 +- .../Gateway/Bedrock/BedrockExceptionTest.php | 26 ++-- .../Bedrock/BedrockImageGatewayTest.php | 31 ++-- .../Bedrock/BedrockTextGatewayTest.php | 94 ++++++------ .../Bedrock/CreatesBedrockClientTest.php | 14 +- .../ComposesSchemaInstructionsTest.php | 12 +- .../Concerns/DecodesStructuredOutputTest.php | 20 +-- .../Concerns/HandlesFailoverErrorsTest.php | 12 +- .../Concerns/ParsesServerSentEventsTest.php | 4 +- .../Gateway/Concerns/ResolvesToolNameTest.php | 10 +- tests/Unit/Gateway/Groq/ToolMappingTest.php | 8 +- tests/Unit/Gateway/InvokesToolsTest.php | 24 +-- .../Unit/Gateway/OpenAi/AudioFilenameTest.php | 38 ++--- tests/Unit/Gateway/TextGenerationLoopTest.php | 40 ++--- .../Gateway/TextGenerationOptionsTest.php | 8 +- tests/Unit/Gateway/Xai/ToolMappingTest.php | 4 +- tests/Unit/ObjectSchemaTest.php | 10 +- tests/Unit/Providers/BedrockProviderTest.php | 48 +++--- .../Unit/Responses/Data/FinishReasonTest.php | 2 +- .../Responses/Data/GeneratedImageTest.php | 12 +- tests/Unit/Responses/Data/MetaTest.php | 12 +- .../Responses/Data/RankedDocumentTest.php | 8 +- tests/Unit/Responses/Data/StepTest.php | 8 +- .../Responses/Data/StoreFileCountsTest.php | 6 +- .../Responses/Data/StructuredStepTest.php | 6 +- tests/Unit/Responses/Data/ToolCallTest.php | 6 +- tests/Unit/Responses/Data/ToolResultTest.php | 6 +- .../Data/TranscriptionSegmentTest.php | 6 +- tests/Unit/Responses/Data/UrlCitationTest.php | 8 +- tests/Unit/Responses/Data/UsageTest.php | 12 +- tests/Unit/Responses/ImageResponseTest.php | 12 +- tests/Unit/Schema/SchemaNormalizerTest.php | 122 +++++++-------- tests/Unit/ToolChoiceTest.php | 40 ++--- tests/Unit/Tools/McpServerToolTest.php | 20 +-- tests/Unit/Tools/McpToolTest.php | 24 +-- 393 files changed, 3712 insertions(+), 4028 deletions(-) create mode 100644 rector.php diff --git a/composer.json b/composer.json index e6956560c..c10b35636 100644 --- a/composer.json +++ b/composer.json @@ -25,13 +25,15 @@ "laravel/serializable-closure": "^2.0" }, "require-dev": { + "driftingly/rector-laravel": "^2.5", "laravel/mcp": "^0.8", "laravel/pint": "^1.26", "mockery/mockery": "^1.6.12", "orchestra/testbench": "^10.6|^11.0", "pestphp/pest": "^3.0|^4.0", "pestphp/pest-plugin-laravel": "^3.0|^4.0", - "phpstan/phpstan": "^2.1" + "phpstan/phpstan": "^2.1", + "rector/rector": "^2.5" }, "suggest": { "laravel/mcp": "Required to use MCP client tools or MCP server tools with Laravel AI agents." diff --git a/rector.php b/rector.php new file mode 100644 index 000000000..80eb2a698 --- /dev/null +++ b/rector.php @@ -0,0 +1,50 @@ +withPaths([ + __DIR__.'/src', + __DIR__.'/tests', + ]) + ->withSkip([ + ReadOnlyPropertyRector::class, + EncapsedStringsToSprintfRector::class, + DisallowedEmptyRuleFixerRector::class, + // Renames public constructor parameters (e.g. mimeType -> mime), breaking named-argument callers. + ClassPropertyAssignToConstructorPromotionRector::class, + // Infers closure param types too narrowly for polymorphic tool lists (Tool|Agent), causing TypeErrors. + AddClosureParamTypeForArrayMapRector::class, + AddClosureParamTypeForArrayReduceRector::class, + AddArrayFunctionClosureParamTypeRector::class, + // Deletes empty-bodied loops, but iterating a generator (e.g. draining a stream) has side effects and must be kept. + RemoveDeadLoopRector::class, + RemoveDeadIfForeachForRector::class, + // Skipped for now to keep the diff focused (no declare(strict_types=1) churn). + SafeDeclareStrictTypesRector::class, + TablePropertyToTableAttributeRector::class, + ]) + ->withPreparedSets( + deadCode: true, + codeQuality: true, + codingStyle: true, + typeDeclarations: true, + earlyReturn: true, + ) + ->withPhpSets(php81: true) + ->withSetProviders(LaravelSetProvider::class) + ->withComposerBased(laravel: true); diff --git a/src/AiManager.php b/src/AiManager.php index 564d23cce..d196f6540 100644 --- a/src/AiManager.php +++ b/src/AiManager.php @@ -62,7 +62,7 @@ class AiManager extends MultipleInstanceManager */ public function audioProvider(?string $name = null): AudioProvider { - return tap($this->instance($name), function ($instance) { + return tap($this->instance($name), function ($instance): void { if (! $instance instanceof AudioProvider) { throw new LogicException('Provider ['.$instance::class.'] does not support audio generation.'); } @@ -90,7 +90,7 @@ public function fakeableAudioProvider(?string $name = null): AudioProvider */ public function embeddingProvider(?string $name = null): EmbeddingProvider { - return tap($this->instance($name), function ($instance) { + return tap($this->instance($name), function ($instance): void { if (! $instance instanceof EmbeddingProvider) { throw new LogicException('Provider ['.$instance::class.'] does not support embedding generation.'); } @@ -118,7 +118,7 @@ public function fakeableEmbeddingProvider(?string $name = null): EmbeddingProvid */ public function rerankingProvider(?string $name = null): RerankingProvider { - return tap($this->instance($name), function ($instance) { + return tap($this->instance($name), function ($instance): void { if (! $instance instanceof RerankingProvider) { throw new LogicException('Provider ['.$instance::class.'] does not support reranking.'); } @@ -146,7 +146,7 @@ public function fakeableRerankingProvider(?string $name = null): RerankingProvid */ public function imageProvider(?string $name = null): ImageProvider { - return tap($this->instance($name), function ($instance) { + return tap($this->instance($name), function ($instance): void { if (! $instance instanceof ImageProvider) { throw new LogicException('Provider ['.$instance::class.'] does not support image generation.'); } @@ -174,7 +174,7 @@ public function fakeableImageProvider(?string $name = null): ImageProvider */ public function textProvider(?string $name = null): TextProvider { - return tap($this->instance($name), function ($instance) { + return tap($this->instance($name), function ($instance): void { if (! $instance instanceof TextProvider) { throw new LogicException('Provider ['.$instance::class.'] does not support text generation.'); } @@ -202,7 +202,7 @@ public function textProviderFor(Agent $agent, ?string $name = null): TextProvide */ public function transcriptionProvider(?string $name = null): TranscriptionProvider { - return tap($this->instance($name), function ($instance) { + return tap($this->instance($name), function ($instance): void { if (! $instance instanceof TranscriptionProvider) { throw new LogicException('Provider ['.$instance::class.'] does not support transcription generation.'); } @@ -230,7 +230,7 @@ public function fakeableTranscriptionProvider(?string $name = null): Transcripti */ public function fileProvider(?string $name = null): FileProvider { - return tap($this->instance($name), function ($instance) { + return tap($this->instance($name), function ($instance): void { if (! $instance instanceof FileProvider) { throw new LogicException('Provider ['.$instance::class.'] does not support file management.'); } @@ -258,7 +258,7 @@ public function fakeableFileProvider(?string $name = null): FileProvider */ public function storeProvider(?string $name = null): StoreProvider { - return tap($this->instance($name), function ($instance) { + return tap($this->instance($name), function ($instance): void { if (! $instance instanceof StoreProvider) { throw new LogicException('Provider ['.$instance::class.'] does not support store management.'); } diff --git a/src/AiServiceProvider.php b/src/AiServiceProvider.php index 73950664e..41a37845e 100644 --- a/src/AiServiceProvider.php +++ b/src/AiServiceProvider.php @@ -12,6 +12,7 @@ use Laravel\Ai\Console\Commands\MakeToolCommand; use Laravel\Ai\Contracts\ConversationStore; use Laravel\Ai\Enums\Lab; +use Laravel\Ai\Responses\AudioResponse; use Laravel\Ai\Storage\DatabaseConversationStore; class AiServiceProvider extends ServiceProvider @@ -19,11 +20,12 @@ class AiServiceProvider extends ServiceProvider /** * Register the package's services. */ + #[\Override] public function register(): void { $this->app->singleton(AiManager::class, fn ($app): AiManager => new AiManager($app)); - $this->app->singleton(ConversationStore::class, fn () => new DatabaseConversationStore( + $this->app->singleton(ConversationStore::class, fn (): DatabaseConversationStore => new DatabaseConversationStore( config('ai.conversations.connection'), )); @@ -79,7 +81,7 @@ public function boot(): void ?string $instructions = null, ?string $model = null, ?int $timeout = null, - ) { + ): AudioResponse { $request = Audio::of($this->value()); if (! is_null($voice)) { @@ -107,8 +109,8 @@ public function boot(): void ) { $resolver = match (true) { $by instanceof Closure => $by, - is_array($by) => fn ($item) => json_encode( - (new Collection($by))->mapWithKeys(fn ($field) => [$field => data_get($item, $field)])->all() + is_array($by) => fn ($item): string|false => json_encode( + (new Collection($by))->mapWithKeys(fn ($field): array => [$field => data_get($item, $field)])->all() ), default => fn ($item) => data_get($item, $by), }; @@ -118,7 +120,7 @@ public function boot(): void ->rerank($query, $provider, $model); return (new Collection($response->results))->map( - fn ($result) => $this->values()[$result->index] + fn ($result): mixed => $this->values()[$result->index] ); }); } diff --git a/src/Concerns/InteractsWithFakeAgents.php b/src/Concerns/InteractsWithFakeAgents.php index 65321cbaa..6a7dde24c 100644 --- a/src/Concerns/InteractsWithFakeAgents.php +++ b/src/Concerns/InteractsWithFakeAgents.php @@ -84,13 +84,11 @@ public function assertAgentWasPrompted( ?string $message = null): self { $callback = is_string($callback) - ? fn ($prompt) => $prompt->prompt === $callback + ? fn ($prompt): bool => $prompt->prompt === $callback : $callback; PHPUnit::assertTrue( - (new Collection($prompts ?? $this->recordedPrompts[$agent] ?? []))->contains(function ($prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($prompts ?? $this->recordedPrompts[$agent] ?? []))->contains(fn ($prompt) => $callback($prompt)), $message ?? 'An expected prompt was not received.' ); @@ -120,13 +118,11 @@ public function assertAgentNotPrompted( ?string $message = null): self { $callback = is_string($callback) - ? fn ($prompt) => $prompt->prompt === $callback + ? fn ($prompt): bool => $prompt->prompt === $callback : $callback; PHPUnit::assertTrue( - (new Collection($prompts ?? $this->recordedPrompts[$agent] ?? []))->doesntContain(function ($prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($prompts ?? $this->recordedPrompts[$agent] ?? []))->doesntContain(fn ($prompt) => $callback($prompt)), $message ?? 'An unexpected prompt was received.' ); diff --git a/src/Concerns/InteractsWithFakeAudio.php b/src/Concerns/InteractsWithFakeAudio.php index 0029291e2..88d49eb37 100644 --- a/src/Concerns/InteractsWithFakeAudio.php +++ b/src/Concerns/InteractsWithFakeAudio.php @@ -54,9 +54,7 @@ public function recordAudioGeneration(AudioPrompt|QueuedAudioPrompt $prompt): se public function assertAudioGenerated(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedAudioGenerations))->contains(function (AudioPrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedAudioGenerations))->contains(fn (AudioPrompt $prompt) => $callback($prompt)), 'An expected audio generation was not recorded.' ); @@ -69,9 +67,7 @@ public function assertAudioGenerated(Closure $callback): self public function assertAudioNotGenerated(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedAudioGenerations))->doesntContain(function (AudioPrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedAudioGenerations))->doesntContain(fn (AudioPrompt $prompt) => $callback($prompt)), 'An unexpected audio generation was recorded.' ); @@ -97,9 +93,7 @@ public function assertNoAudioGenerated(): self public function assertAudioQueued(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedQueuedAudioGenerations))->contains(function (QueuedAudioPrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedQueuedAudioGenerations))->contains(fn (QueuedAudioPrompt $prompt) => $callback($prompt)), 'An expected queued audio generation was not recorded.' ); @@ -112,9 +106,7 @@ public function assertAudioQueued(Closure $callback): self public function assertAudioNotQueued(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedQueuedAudioGenerations))->doesntContain(function (QueuedAudioPrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedQueuedAudioGenerations))->doesntContain(fn (QueuedAudioPrompt $prompt) => $callback($prompt)), 'An unexpected queued audio generation was recorded.' ); diff --git a/src/Concerns/InteractsWithFakeEmbeddings.php b/src/Concerns/InteractsWithFakeEmbeddings.php index 49084166f..5449a44f9 100644 --- a/src/Concerns/InteractsWithFakeEmbeddings.php +++ b/src/Concerns/InteractsWithFakeEmbeddings.php @@ -54,9 +54,7 @@ public function recordEmbeddingsGeneration(EmbeddingsPrompt|QueuedEmbeddingsProm public function assertEmbeddingsGenerated(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedEmbeddingsGenerations))->contains(function (EmbeddingsPrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedEmbeddingsGenerations))->contains(fn (EmbeddingsPrompt $prompt) => $callback($prompt)), 'An expected embeddings generation was not recorded.' ); @@ -69,9 +67,7 @@ public function assertEmbeddingsGenerated(Closure $callback): self public function assertEmbeddingsNotGenerated(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedEmbeddingsGenerations))->doesntContain(function (EmbeddingsPrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedEmbeddingsGenerations))->doesntContain(fn (EmbeddingsPrompt $prompt) => $callback($prompt)), 'An unexpected embeddings generation was recorded.' ); @@ -97,9 +93,7 @@ public function assertNoEmbeddingsGenerated(): self public function assertEmbeddingsQueued(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedQueuedEmbeddingsGenerations))->contains(function (QueuedEmbeddingsPrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedQueuedEmbeddingsGenerations))->contains(fn (QueuedEmbeddingsPrompt $prompt) => $callback($prompt)), 'An expected queued embeddings generation was not recorded.' ); @@ -112,9 +106,7 @@ public function assertEmbeddingsQueued(Closure $callback): self public function assertEmbeddingsNotQueued(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedQueuedEmbeddingsGenerations))->doesntContain(function (QueuedEmbeddingsPrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedQueuedEmbeddingsGenerations))->doesntContain(fn (QueuedEmbeddingsPrompt $prompt) => $callback($prompt)), 'An unexpected queued embeddings generation was recorded.' ); diff --git a/src/Concerns/InteractsWithFakeFiles.php b/src/Concerns/InteractsWithFakeFiles.php index a99cfd69e..38b3e848f 100644 --- a/src/Concerns/InteractsWithFakeFiles.php +++ b/src/Concerns/InteractsWithFakeFiles.php @@ -61,9 +61,7 @@ public function recordFileDeletion(string $fileId): self public function assertFileUploaded(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedFileUploads))->contains(function (array $upload) use ($callback) { - return $callback($upload['file']); - }), + (new Collection($this->recordedFileUploads))->contains(fn (array $upload) => $callback($upload['file'])), 'An expected file upload was not recorded.' ); @@ -76,9 +74,7 @@ public function assertFileUploaded(Closure $callback): self public function assertFileNotUploaded(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedFileUploads))->doesntContain(function (array $upload) use ($callback) { - return $callback($upload['file']); - }), + (new Collection($this->recordedFileUploads))->doesntContain(fn (array $upload) => $callback($upload['file'])), 'An unexpected file upload was recorded.' ); @@ -105,13 +101,11 @@ public function assertFileDeleted(Closure|string $callback): self { if (is_string($callback)) { $fileId = $callback; - $callback = fn ($id) => $id === $fileId; + $callback = fn ($id): bool => $id === $fileId; } PHPUnit::assertTrue( - (new Collection($this->recordedFileDeletions))->contains(function (string $id) use ($callback) { - return $callback($id); - }), + (new Collection($this->recordedFileDeletions))->contains(fn (string $id) => $callback($id)), 'An expected file deletion was not recorded.' ); @@ -125,13 +119,11 @@ public function assertFileNotDeleted(Closure|string $callback): self { if (is_string($callback)) { $fileId = $callback; - $callback = fn ($id) => $id === $fileId; + $callback = fn ($id): bool => $id === $fileId; } PHPUnit::assertTrue( - (new Collection($this->recordedFileDeletions))->doesntContain(function (string $id) use ($callback) { - return $callback($id); - }), + (new Collection($this->recordedFileDeletions))->doesntContain(fn (string $id) => $callback($id)), 'An unexpected file deletion was recorded.' ); diff --git a/src/Concerns/InteractsWithFakeImages.php b/src/Concerns/InteractsWithFakeImages.php index faf160e29..c4409a988 100644 --- a/src/Concerns/InteractsWithFakeImages.php +++ b/src/Concerns/InteractsWithFakeImages.php @@ -54,9 +54,7 @@ public function recordImageGeneration(ImagePrompt|QueuedImagePrompt $prompt): se public function assertImageGenerated(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedImageGenerations))->contains(function (ImagePrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedImageGenerations))->contains(fn (ImagePrompt $prompt) => $callback($prompt)), 'An expected image generation was not recorded.' ); @@ -69,9 +67,7 @@ public function assertImageGenerated(Closure $callback): self public function assertImageNotGenerated(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedImageGenerations))->doesntContain(function (ImagePrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedImageGenerations))->doesntContain(fn (ImagePrompt $prompt) => $callback($prompt)), 'An unexpected image generation was recorded.' ); @@ -97,9 +93,7 @@ public function assertNoImagesGenerated(): self public function assertImageQueued(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedQueuedImageGenerations))->contains(function (QueuedImagePrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedQueuedImageGenerations))->contains(fn (QueuedImagePrompt $prompt) => $callback($prompt)), 'An expected queued image generation was not recorded.' ); @@ -112,9 +106,7 @@ public function assertImageQueued(Closure $callback): self public function assertImageNotQueued(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedQueuedImageGenerations))->doesntContain(function (QueuedImagePrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedQueuedImageGenerations))->doesntContain(fn (QueuedImagePrompt $prompt) => $callback($prompt)), 'An unexpected queued image generation was recorded.' ); diff --git a/src/Concerns/InteractsWithFakeReranking.php b/src/Concerns/InteractsWithFakeReranking.php index b805b4789..dfdac43cc 100644 --- a/src/Concerns/InteractsWithFakeReranking.php +++ b/src/Concerns/InteractsWithFakeReranking.php @@ -44,9 +44,7 @@ public function recordReranking(RerankingPrompt $prompt): self public function assertReranked(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedRerankings))->contains(function (RerankingPrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedRerankings))->contains(fn (RerankingPrompt $prompt) => $callback($prompt)), 'An expected reranking was not recorded.' ); @@ -59,9 +57,7 @@ public function assertReranked(Closure $callback): self public function assertNotReranked(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedRerankings))->doesntContain(function (RerankingPrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedRerankings))->doesntContain(fn (RerankingPrompt $prompt) => $callback($prompt)), 'An unexpected reranking was recorded.' ); diff --git a/src/Concerns/InteractsWithFakeStores.php b/src/Concerns/InteractsWithFakeStores.php index 9b65bd9e4..6f35c4e47 100644 --- a/src/Concerns/InteractsWithFakeStores.php +++ b/src/Concerns/InteractsWithFakeStores.php @@ -114,18 +114,16 @@ public function assertStoreCreated(Closure|string $callback): self { if (is_string($callback)) { $name = $callback; - $callback = fn ($n) => $n === $name; + $callback = fn ($n): bool => $n === $name; } PHPUnit::assertTrue( - (new Collection($this->recordedStoreCreations))->contains(function (array $creation) use ($callback) { - return $callback( - $creation['name'], - $creation['description'], - $creation['fileIds'], - $creation['expiresWhenIdleFor'], - ); - }), + (new Collection($this->recordedStoreCreations))->contains(fn (array $creation) => $callback( + $creation['name'], + $creation['description'], + $creation['fileIds'], + $creation['expiresWhenIdleFor'], + )), 'An expected store creation was not recorded.' ); @@ -139,18 +137,16 @@ public function assertStoreNotCreated(Closure|string $callback): self { if (is_string($callback)) { $name = $callback; - $callback = fn ($n) => $n === $name; + $callback = fn ($n): bool => $n === $name; } PHPUnit::assertTrue( - (new Collection($this->recordedStoreCreations))->doesntContain(function (array $creation) use ($callback) { - return $callback( - $creation['name'], - $creation['description'], - $creation['fileIds'], - $creation['expiresWhenIdleFor'], - ); - }), + (new Collection($this->recordedStoreCreations))->doesntContain(fn (array $creation) => $callback( + $creation['name'], + $creation['description'], + $creation['fileIds'], + $creation['expiresWhenIdleFor'], + )), 'An unexpected store creation was recorded.' ); @@ -177,13 +173,11 @@ public function assertStoreDeleted(Closure|string $callback): self { if (is_string($callback)) { $storeId = $callback; - $callback = fn ($id) => $id === $storeId; + $callback = fn ($id): bool => $id === $storeId; } PHPUnit::assertTrue( - (new Collection($this->recordedStoreDeletions))->contains(function (string $id) use ($callback) { - return $callback($id); - }), + (new Collection($this->recordedStoreDeletions))->contains(fn (string $id) => $callback($id)), 'An expected store deletion was not recorded.' ); @@ -197,13 +191,11 @@ public function assertStoreNotDeleted(Closure|string $callback): self { if (is_string($callback)) { $storeId = $callback; - $callback = fn ($id) => $id === $storeId; + $callback = fn ($id): bool => $id === $storeId; } PHPUnit::assertTrue( - (new Collection($this->recordedStoreDeletions))->doesntContain(function (string $id) use ($callback) { - return $callback($id); - }), + (new Collection($this->recordedStoreDeletions))->doesntContain(fn (string $id) => $callback($id)), 'An unexpected store deletion was recorded.' ); @@ -231,9 +223,7 @@ public function assertFileAddedToStore(Closure|string $storeId, ?string $fileId $callback = $this->fileMatchingCallback($storeId, $fileId); PHPUnit::assertTrue( - (new Collection($this->recordedFileAdditions))->contains(function (array $addition) use ($callback) { - return $callback($addition['storeId'], $addition['file']); - }), + (new Collection($this->recordedFileAdditions))->contains(fn (array $addition) => $callback($addition['storeId'], $addition['file'])), 'An expected file addition was not recorded.' ); @@ -248,9 +238,7 @@ public function assertFileNotAddedToStore(Closure|string $storeId, ?string $file $callback = $this->fileMatchingCallback($storeId, $fileId); PHPUnit::assertTrue( - (new Collection($this->recordedFileAdditions))->doesntContain(function (array $addition) use ($callback) { - return $callback($addition['storeId'], $addition['file']); - }), + (new Collection($this->recordedFileAdditions))->doesntContain(fn (array $addition) => $callback($addition['storeId'], $addition['file'])), 'An unexpected file addition was recorded.' ); @@ -265,9 +253,7 @@ public function assertFileRemovedFromStore(Closure|string $storeId, ?string $fil $callback = $this->fileMatchingCallback($storeId, $fileId); PHPUnit::assertTrue( - (new Collection($this->recordedFileRemovals))->contains(function (array $removal) use ($callback) { - return $callback($removal['storeId'], $removal['fileId']); - }), + (new Collection($this->recordedFileRemovals))->contains(fn (array $removal) => $callback($removal['storeId'], $removal['fileId'])), 'An expected file removal was not recorded.' ); @@ -282,9 +268,7 @@ public function assertFileNotRemovedFromStore(Closure|string $storeId, ?string $ $callback = $this->fileMatchingCallback($storeId, $fileId); PHPUnit::assertTrue( - (new Collection($this->recordedFileRemovals))->doesntContain(function (array $removal) use ($callback) { - return $callback($removal['storeId'], $removal['fileId']); - }), + (new Collection($this->recordedFileRemovals))->doesntContain(fn (array $removal) => $callback($removal['storeId'], $removal['fileId'])), 'An unexpected file removal was recorded.' ); @@ -304,7 +288,7 @@ protected function fileMatchingCallback(Closure|string $storeId, ?string $fileId $expectedFileId = $fileId !== null ? (str_starts_with($fileId, 'fake_file_') ? $fileId : Files::fakeId($fileId)) : null; - return fn ($s, $f) => $s === $expectedStoreId && $this->fileIdMatches($f, $expectedFileId); + return fn ($s, $f): bool => $s === $expectedStoreId && $this->fileIdMatches($f, $expectedFileId); } /** diff --git a/src/Concerns/InteractsWithFakeTranscriptions.php b/src/Concerns/InteractsWithFakeTranscriptions.php index 472c143c4..84bcb9702 100644 --- a/src/Concerns/InteractsWithFakeTranscriptions.php +++ b/src/Concerns/InteractsWithFakeTranscriptions.php @@ -54,9 +54,7 @@ public function recordTranscriptionGeneration(TranscriptionPrompt|QueuedTranscri public function assertTranscriptionGenerated(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedTranscriptionGenerations))->contains(function (TranscriptionPrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedTranscriptionGenerations))->contains(fn (TranscriptionPrompt $prompt) => $callback($prompt)), 'An expected transcription generation was not recorded.' ); @@ -69,9 +67,7 @@ public function assertTranscriptionGenerated(Closure $callback): self public function assertTranscriptionNotGenerated(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedTranscriptionGenerations))->doesntContain(function (TranscriptionPrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedTranscriptionGenerations))->doesntContain(fn (TranscriptionPrompt $prompt) => $callback($prompt)), 'An unexpected transcription generation was recorded.' ); @@ -97,9 +93,7 @@ public function assertNoTranscriptionsGenerated(): self public function assertTranscriptionQueued(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedQueuedTranscriptionGenerations))->contains(function (QueuedTranscriptionPrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedQueuedTranscriptionGenerations))->contains(fn (QueuedTranscriptionPrompt $prompt) => $callback($prompt)), 'An expected queued transcription generation was not recorded.' ); @@ -112,9 +106,7 @@ public function assertTranscriptionQueued(Closure $callback): self public function assertTranscriptionNotQueued(Closure $callback): self { PHPUnit::assertTrue( - (new Collection($this->recordedQueuedTranscriptionGenerations))->doesntContain(function (QueuedTranscriptionPrompt $prompt) use ($callback) { - return $callback($prompt); - }), + (new Collection($this->recordedQueuedTranscriptionGenerations))->doesntContain(fn (QueuedTranscriptionPrompt $prompt) => $callback($prompt)), 'An unexpected queued transcription generation was recorded.' ); diff --git a/src/Console/Commands/ChatCommand.php b/src/Console/Commands/ChatCommand.php index 0c7fd5eae..942111757 100644 --- a/src/Console/Commands/ChatCommand.php +++ b/src/Console/Commands/ChatCommand.php @@ -2,7 +2,10 @@ namespace Laravel\Ai\Console\Commands; +use Illuminate\Console\Attributes\Description; +use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; +use Laravel\Ai\Responses\AgentResponse; use Laravel\Ai\Responses\StructuredAgentResponse; use function Laravel\Ai\agent; @@ -10,22 +13,10 @@ use function Laravel\Prompts\spin; use function Laravel\Prompts\textarea; +#[Description('Chat with one of your agents')] +#[Signature('agent:chat')] class ChatCommand extends Command { - /** - * The name and signature of the console command. - * - * @var string - */ - protected $signature = 'agent:chat'; - - /** - * The console command description. - * - * @var string - */ - protected $description = 'Chat with one of your agents'; - /** * Execute the console command. */ @@ -39,7 +30,7 @@ public function handle(): int $prompt = textarea('Prompt...'); $response = spin( - fn () => $agent->prompt($prompt), + fn (): AgentResponse => $agent->prompt($prompt), message: 'Thinking...', ); @@ -47,7 +38,7 @@ public function handle(): int $response = json_encode($response->structured, JSON_PRETTY_PRINT); } - note((string) $response.PHP_EOL); + note($response.PHP_EOL); } return Command::SUCCESS; diff --git a/src/Console/Commands/MakeAgentCommand.php b/src/Console/Commands/MakeAgentCommand.php index 3d986d5fd..bc5e199d0 100644 --- a/src/Console/Commands/MakeAgentCommand.php +++ b/src/Console/Commands/MakeAgentCommand.php @@ -2,6 +2,7 @@ namespace Laravel\Ai\Console\Commands; +use Illuminate\Console\Attributes\Description; use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; @@ -11,6 +12,7 @@ use function Laravel\Prompts\confirm; #[AsCommand(name: 'make:agent')] +#[Description('Create a new agent')] class MakeAgentCommand extends GeneratorCommand { /** @@ -20,13 +22,6 @@ class MakeAgentCommand extends GeneratorCommand */ protected $name = 'make:agent'; - /** - * The console command description. - * - * @var string - */ - protected $description = 'Create a new agent'; - /** * The type of class being generated. * @@ -40,6 +35,7 @@ class MakeAgentCommand extends GeneratorCommand * @param string $rootNamespace * @return string */ + #[\Override] protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Ai\Agents'; @@ -77,6 +73,7 @@ protected function resolveStubPath($stub) * * @return array */ + #[\Override] protected function getOptions() { return [ @@ -90,6 +87,7 @@ protected function getOptions() * * @return void */ + #[\Override] protected function afterPromptingForMissingArguments(InputInterface $input, OutputInterface $output) { if ($this->didReceiveOptions($input)) { diff --git a/src/Console/Commands/MakeAgentMiddlewareCommand.php b/src/Console/Commands/MakeAgentMiddlewareCommand.php index 3e62b4087..20e69056b 100644 --- a/src/Console/Commands/MakeAgentMiddlewareCommand.php +++ b/src/Console/Commands/MakeAgentMiddlewareCommand.php @@ -2,11 +2,13 @@ namespace Laravel\Ai\Console\Commands; +use Illuminate\Console\Attributes\Description; use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:agent-middleware')] +#[Description('Create a new agent middleware')] class MakeAgentMiddlewareCommand extends GeneratorCommand { /** @@ -16,13 +18,6 @@ class MakeAgentMiddlewareCommand extends GeneratorCommand */ protected $name = 'make:agent-middleware'; - /** - * The console command description. - * - * @var string - */ - protected $description = 'Create a new agent middleware'; - /** * The type of class being generated. * @@ -36,6 +31,7 @@ class MakeAgentMiddlewareCommand extends GeneratorCommand * @param string $rootNamespace * @return string */ + #[\Override] protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Ai\Middleware'; @@ -69,6 +65,7 @@ protected function resolveStubPath($stub) * * @return array */ + #[\Override] protected function getOptions() { return [ diff --git a/src/Console/Commands/MakeToolCommand.php b/src/Console/Commands/MakeToolCommand.php index a3cfa4f2e..b440b1fa4 100644 --- a/src/Console/Commands/MakeToolCommand.php +++ b/src/Console/Commands/MakeToolCommand.php @@ -2,11 +2,13 @@ namespace Laravel\Ai\Console\Commands; +use Illuminate\Console\Attributes\Description; use Illuminate\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'make:tool')] +#[Description('Create a new agent tool')] class MakeToolCommand extends GeneratorCommand { /** @@ -16,13 +18,6 @@ class MakeToolCommand extends GeneratorCommand */ protected $name = 'make:tool'; - /** - * The console command description. - * - * @var string - */ - protected $description = 'Create a new agent tool'; - /** * The type of class being generated. * @@ -36,6 +31,7 @@ class MakeToolCommand extends GeneratorCommand * @param string $rootNamespace * @return string */ + #[\Override] protected function getDefaultNamespace($rootNamespace) { return $rootNamespace.'\Ai\Tools'; @@ -69,6 +65,7 @@ protected function resolveStubPath($stub) * * @return array */ + #[\Override] protected function getOptions() { return [ diff --git a/src/Embeddings.php b/src/Embeddings.php index 74fb5614a..072238323 100644 --- a/src/Embeddings.php +++ b/src/Embeddings.php @@ -94,13 +94,13 @@ public static function fakeEmbedding(int $dimensions): array { // Generate random values... $values = array_map( - fn () => (mt_rand() / mt_getrandmax()) * 2 - 1, + fn (): float|int => (mt_rand() / mt_getrandmax()) * 2 - 1, range(1, $dimensions) ); // Normalize the vector (unit length)... - $magnitude = sqrt(array_sum(array_map(fn ($v) => $v * $v, $values))); + $magnitude = sqrt(array_sum(array_map(fn ($v): int|float => $v * $v, $values))); - return array_map(fn ($v) => $v / $magnitude, $values); + return array_map(fn ($v): float => $v / $magnitude, $values); } } diff --git a/src/FakePendingDispatch.php b/src/FakePendingDispatch.php index 3d8d85462..f52d9f66d 100644 --- a/src/FakePendingDispatch.php +++ b/src/FakePendingDispatch.php @@ -15,8 +15,8 @@ public function __construct() {} * Set the desired connection for the job. * * @param \BackedEnum|string|null $connection - * @return $this */ + #[\Override] public function onConnection($connection): static { return $this; @@ -26,8 +26,8 @@ public function onConnection($connection): static * Set the desired queue for the job. * * @param \BackedEnum|string|null $queue - * @return $this */ + #[\Override] public function onQueue($queue): static { return $this; @@ -39,8 +39,8 @@ public function onQueue($queue): static * This feature is only supported by some queues, such as Amazon SQS. * * @param \UnitEnum|string $group - * @return $this */ + #[\Override] public function onGroup($group): static { return $this; @@ -52,8 +52,8 @@ public function onGroup($group): static * This feature is only supported by some queues, such as Amazon SQS FIFO. * * @param callable|null $deduplicator - * @return $this */ + #[\Override] public function withDeduplicator($deduplicator): static { return $this; @@ -63,8 +63,8 @@ public function withDeduplicator($deduplicator): static * Set the desired connection for the chain. * * @param \BackedEnum|string|null $connection - * @return $this */ + #[\Override] public function allOnConnection($connection): static { return $this; @@ -74,8 +74,8 @@ public function allOnConnection($connection): static * Set the desired queue for the chain. * * @param \BackedEnum|string|null $queue - * @return $this */ + #[\Override] public function allOnQueue($queue): static { return $this; @@ -85,8 +85,8 @@ public function allOnQueue($queue): static * Set the desired delay in seconds for the job. * * @param \DateTimeInterface|\DateInterval|int|null $delay - * @return $this */ + #[\Override] public function delay($delay): static { return $this; @@ -94,9 +94,8 @@ public function delay($delay): static /** * Set the delay for the job to zero seconds. - * - * @return $this */ + #[\Override] public function withoutDelay(): static { return $this; @@ -104,9 +103,8 @@ public function withoutDelay(): static /** * Indicate that the job should be dispatched after all database transactions have committed. - * - * @return $this */ + #[\Override] public function afterCommit(): static { return $this; @@ -114,9 +112,8 @@ public function afterCommit(): static /** * Indicate that the job should not wait until database transactions have been committed before dispatching. - * - * @return $this */ + #[\Override] public function beforeCommit(): static { return $this; @@ -126,8 +123,8 @@ public function beforeCommit(): static * Set the jobs that should run if this job is successful. * * @param array $chain - * @return $this */ + #[\Override] public function chain($chain): static { return $this; @@ -137,8 +134,8 @@ public function chain($chain): static * Indicate that the job should be dispatched after the response is sent to the browser. * * @param bool $afterResponse - * @return $this */ + #[\Override] public function afterResponse($afterResponse = true): static { return $this; @@ -149,6 +146,7 @@ public function afterResponse($afterResponse = true): static * * @return bool */ + #[\Override] protected function shouldDispatch() { return true; @@ -159,6 +157,7 @@ protected function shouldDispatch() * * @return mixed */ + #[\Override] public function getJob() { return new class {}; @@ -169,8 +168,8 @@ public function getJob() * * @param string $method * @param array $parameters - * @return $this */ + #[\Override] public function __call($method, $parameters): static { return $this; @@ -178,9 +177,8 @@ public function __call($method, $parameters): static /** * Handle the object's destruction. - * - * @return void */ + #[\Override] public function __destruct() { // diff --git a/src/Files/Base64Audio.php b/src/Files/Base64Audio.php index 357eea6c7..f41c3cc45 100644 --- a/src/Files/Base64Audio.php +++ b/src/Files/Base64Audio.php @@ -47,6 +47,7 @@ public function content(): string /** * Get the file's MIME type. */ + #[\Override] public function mimeType(): ?string { return $this->mime; diff --git a/src/Files/Base64Document.php b/src/Files/Base64Document.php index 1617ffaef..7a187f75b 100644 --- a/src/Files/Base64Document.php +++ b/src/Files/Base64Document.php @@ -25,6 +25,7 @@ public function __construct(public string $base64, ?string $mimeType = null) /** * Create a new instance from an uploaded file. */ + #[\Override] public static function fromUpload(UploadedFile $file, ?string $mimeType = null): self { return new self( @@ -44,6 +45,7 @@ public function content(): string /** * Get the file's MIME type. */ + #[\Override] public function mimeType(): ?string { return $this->mime; diff --git a/src/Files/Base64Image.php b/src/Files/Base64Image.php index 4849a5449..a3b08eaf6 100644 --- a/src/Files/Base64Image.php +++ b/src/Files/Base64Image.php @@ -32,6 +32,7 @@ public function content(): string /** * Get the file's MIME type. */ + #[\Override] public function mimeType(): ?string { return $this->mime; diff --git a/src/Files/LocalAudio.php b/src/Files/LocalAudio.php index 983edc5df..4104f5293 100644 --- a/src/Files/LocalAudio.php +++ b/src/Files/LocalAudio.php @@ -45,6 +45,7 @@ public function content(): string /** * Get the displayable name of the file. */ + #[\Override] public function name(): ?string { return $this->name ?? basename($this->path); @@ -53,6 +54,7 @@ public function name(): ?string /** * Get the file's MIME type. */ + #[\Override] public function mimeType(): ?string { return $this->mime ?? (new Filesystem)->mimeType($this->path); diff --git a/src/Files/LocalDocument.php b/src/Files/LocalDocument.php index 5c3c08f50..f5c20852f 100644 --- a/src/Files/LocalDocument.php +++ b/src/Files/LocalDocument.php @@ -42,6 +42,7 @@ public function content(): string /** * Get the displayable name of the file. */ + #[\Override] public function name(): ?string { return $this->name ?? basename($this->path); @@ -50,6 +51,7 @@ public function name(): ?string /** * Get the file's MIME type. */ + #[\Override] public function mimeType(): ?string { return $this->mime ?? (new Filesystem)->mimeType($this->path); diff --git a/src/Files/LocalImage.php b/src/Files/LocalImage.php index e2eef3a48..e9a5c6bec 100644 --- a/src/Files/LocalImage.php +++ b/src/Files/LocalImage.php @@ -42,6 +42,7 @@ public function content(): string /** * Get the displayable name of the file. */ + #[\Override] public function name(): ?string { return $this->name ?? basename($this->path); @@ -50,6 +51,7 @@ public function name(): ?string /** * Get the file's MIME type. */ + #[\Override] public function mimeType(): ?string { return $this->mime ?? ((new Filesystem)->mimeType($this->path) ?: null); diff --git a/src/Files/RemoteAudio.php b/src/Files/RemoteAudio.php index 7a8d8e0ac..91bda146a 100644 --- a/src/Files/RemoteAudio.php +++ b/src/Files/RemoteAudio.php @@ -14,7 +14,8 @@ class RemoteAudio extends Audio implements Arrayable, JsonSerializable, StorableFile, TranscribableAudio { - use CanBeUploadedToProvider, HasRemoteContent; + use CanBeUploadedToProvider; + use HasRemoteContent; public function __construct(public string $url, ?string $mimeType = null) { diff --git a/src/Files/RemoteDocument.php b/src/Files/RemoteDocument.php index 2d4f7cef4..78333bafe 100644 --- a/src/Files/RemoteDocument.php +++ b/src/Files/RemoteDocument.php @@ -11,7 +11,8 @@ class RemoteDocument extends Document implements Arrayable, JsonSerializable, StorableFile { - use CanBeUploadedToProvider, HasRemoteContent; + use CanBeUploadedToProvider; + use HasRemoteContent; public function __construct(public string $url, ?string $mimeType = null) { diff --git a/src/Files/RemoteImage.php b/src/Files/RemoteImage.php index eddad4463..ab93259b3 100644 --- a/src/Files/RemoteImage.php +++ b/src/Files/RemoteImage.php @@ -11,7 +11,8 @@ class RemoteImage extends Image implements Arrayable, JsonSerializable, StorableFile { - use CanBeUploadedToProvider, HasRemoteContent; + use CanBeUploadedToProvider; + use HasRemoteContent; public function __construct(public string $url, ?string $mimeType = null) { diff --git a/src/Files/S3Document.php b/src/Files/S3Document.php index a3707a108..b755cbd87 100644 --- a/src/Files/S3Document.php +++ b/src/Files/S3Document.php @@ -22,6 +22,7 @@ public function __construct( /** * Get the displayable name of the file. */ + #[\Override] public function name(): ?string { $path = parse_url($this->url, PHP_URL_PATH); @@ -42,6 +43,7 @@ public function content(): string /** * Get the file's MIME type. */ + #[\Override] public function mimeType(): ?string { return $this->mime; diff --git a/src/Files/StoredAudio.php b/src/Files/StoredAudio.php index 6b579ec78..7f6e81f00 100644 --- a/src/Files/StoredAudio.php +++ b/src/Files/StoredAudio.php @@ -39,6 +39,7 @@ public function content(): string /** * Get the displayable name of the file. */ + #[\Override] public function name(): ?string { return $this->name ?? basename($this->path); @@ -47,6 +48,7 @@ public function name(): ?string /** * Get the file's MIME type. */ + #[\Override] public function mimeType(): ?string { /** @var FilesystemAdapter $disk */ diff --git a/src/Files/StoredDocument.php b/src/Files/StoredDocument.php index db5fd5f98..27d5bf01d 100644 --- a/src/Files/StoredDocument.php +++ b/src/Files/StoredDocument.php @@ -36,6 +36,7 @@ public function content(): string /** * Get the displayable name of the file. */ + #[\Override] public function name(): ?string { return $this->name ?? basename($this->path); @@ -44,6 +45,7 @@ public function name(): ?string /** * Get the file's MIME type. */ + #[\Override] public function mimeType(): ?string { /** @var FilesystemAdapter $disk */ diff --git a/src/Files/StoredImage.php b/src/Files/StoredImage.php index 6f55035e5..3b36fb90e 100644 --- a/src/Files/StoredImage.php +++ b/src/Files/StoredImage.php @@ -36,6 +36,7 @@ public function content(): string /** * Get the displayable name of the file. */ + #[\Override] public function name(): ?string { return $this->name ?? basename($this->path); @@ -44,6 +45,7 @@ public function name(): ?string /** * Get the file's MIME type. */ + #[\Override] public function mimeType(): ?string { /** @var FilesystemAdapter $disk */ diff --git a/src/Gateway/Anthropic/Concerns/BuildsTextRequests.php b/src/Gateway/Anthropic/Concerns/BuildsTextRequests.php index f99e5d844..954b44fbc 100644 --- a/src/Gateway/Anthropic/Concerns/BuildsTextRequests.php +++ b/src/Gateway/Anthropic/Concerns/BuildsTextRequests.php @@ -85,7 +85,7 @@ protected function resolveToolChoice(?array $schema, array $tools, array $provid : ['type' => 'tool', 'name' => 'output_structured_data']; } - if (! $toolChoice) { + if (! $toolChoice instanceof ToolChoice) { return ['type' => 'auto']; } diff --git a/src/Gateway/Anthropic/Concerns/HandlesTextStreaming.php b/src/Gateway/Anthropic/Concerns/HandlesTextStreaming.php index 0b0707a9a..1ed80b389 100644 --- a/src/Gateway/Anthropic/Concerns/HandlesTextStreaming.php +++ b/src/Gateway/Anthropic/Concerns/HandlesTextStreaming.php @@ -14,6 +14,7 @@ use Laravel\Ai\Streaming\Events\ReasoningDelta; use Laravel\Ai\Streaming\Events\ReasoningEnd; use Laravel\Ai\Streaming\Events\ReasoningStart; +use Laravel\Ai\Streaming\Events\StreamEvent; use Laravel\Ai\Streaming\Events\StreamStart; use Laravel\Ai\Streaming\Events\TextDelta; use Laravel\Ai\Streaming\Events\TextEnd; @@ -54,7 +55,7 @@ protected function processTextStream( $usage = null; $stopReason = ''; - $emitTextStart = function () use (&$textStartEmitted, $messageId, $invocationId) { + $emitTextStart = function () use (&$textStartEmitted, $messageId, $invocationId): ?\Laravel\Ai\Streaming\Events\StreamEvent { if ($textStartEmitted) { return null; } @@ -68,7 +69,7 @@ protected function processTextStream( ))->withInvocationId($invocationId); }; - $emitReasoningStart = function () use (&$reasoningStartEmitted, &$reasoningId, $invocationId) { + $emitReasoningStart = function () use (&$reasoningStartEmitted, &$reasoningId, $invocationId): ?\Laravel\Ai\Streaming\Events\StreamEvent { if ($reasoningStartEmitted) { return null; } @@ -124,14 +125,14 @@ protected function processTextStream( if ($blockType === 'text') { $currentBlockText = ''; - if ($event = $emitTextStart()) { + if (($event = $emitTextStart()) instanceof StreamEvent) { yield $event; } } elseif ($blockType === 'thinking') { $currentThinkingText = ''; $currentSignature = ''; - if ($event = $emitReasoningStart()) { + if (($event = $emitReasoningStart()) instanceof StreamEvent) { yield $event; } } elseif ($blockType === 'tool_use') { @@ -179,7 +180,7 @@ protected function processTextStream( $textDelta = (string) ($data['delta']['text'] ?? ''); if ($textDelta !== '') { - if ($event = $emitTextStart()) { + if (($event = $emitTextStart()) instanceof StreamEvent) { yield $event; } @@ -197,7 +198,7 @@ protected function processTextStream( $delta = (string) ($data['delta']['thinking'] ?? ''); if ($delta !== '') { - if ($event = $emitReasoningStart()) { + if (($event = $emitReasoningStart()) instanceof StreamEvent) { yield $event; } diff --git a/src/Gateway/Anthropic/Concerns/MapsAttachments.php b/src/Gateway/Anthropic/Concerns/MapsAttachments.php index e10494c57..773ba38fd 100644 --- a/src/Gateway/Anthropic/Concerns/MapsAttachments.php +++ b/src/Gateway/Anthropic/Concerns/MapsAttachments.php @@ -26,7 +26,7 @@ trait MapsAttachments */ protected function mapAttachments(Collection $attachments): array { - return $attachments->map(function (File|UploadedFile $attachment) { + return $attachments->map(function (File|UploadedFile $attachment): array { $mapped = match (true) { $attachment instanceof ProviderImage => [ 'type' => 'image', @@ -64,7 +64,7 @@ protected function mapAttachments(Collection $attachments): array 'type' => 'base64', 'media_type' => $attachment->mimeType(), 'data' => base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) ), ], ], @@ -79,16 +79,16 @@ protected function mapAttachments(Collection $attachments): array 'type' => 'document', 'source' => $this->documentSource( $attachment->mime, - fn () => base64_decode($attachment->base64), - fn () => $attachment->base64, + fn (): string => base64_decode($attachment->base64), + fn (): string => $attachment->base64, ), ], $attachment instanceof LocalDocument => [ 'type' => 'document', 'source' => $this->documentSource( $attachment->mimeType(), - fn () => file_get_contents($attachment->path), - fn () => base64_encode(file_get_contents($attachment->path)), + fn (): string|false => file_get_contents($attachment->path), + fn (): string => base64_encode(file_get_contents($attachment->path)), ), ], $attachment instanceof RemoteDocument => [ @@ -103,7 +103,7 @@ protected function mapAttachments(Collection $attachments): array 'source' => $this->documentSource( $attachment->mimeType(), fn () => Storage::disk($attachment->disk)->get($attachment->path), - fn () => base64_encode(Storage::disk($attachment->disk)->get($attachment->path)), + fn (): string => base64_encode((string) Storage::disk($attachment->disk)->get($attachment->path)), ), ], $attachment instanceof UploadedFile && $this->isImage($attachment) => [ @@ -119,10 +119,10 @@ protected function mapAttachments(Collection $attachments): array 'source' => $this->documentSource( $attachment->getClientMimeType(), fn () => $attachment->get(), - fn () => base64_encode($attachment->get()), + fn (): string => base64_encode($attachment->get()), ), ], - default => throw new InvalidArgumentException('Unsupported attachment type ['.get_class($attachment).']'), + default => throw new InvalidArgumentException('Unsupported attachment type ['.$attachment::class.']'), }; if (($mapped['type'] ?? '') === 'document' && $attachment instanceof File && filled($attachment->name)) { @@ -167,6 +167,7 @@ protected function isImage(UploadedFile $attachment): bool 'image/png', 'image/gif', 'image/webp', - ]); + ], + true); } } diff --git a/src/Gateway/Anthropic/Concerns/MapsMessages.php b/src/Gateway/Anthropic/Concerns/MapsMessages.php index c2533a138..0917c9121 100644 --- a/src/Gateway/Anthropic/Concerns/MapsMessages.php +++ b/src/Gateway/Anthropic/Concerns/MapsMessages.php @@ -70,7 +70,7 @@ protected function mapAssistantMessage(AssistantMessage|Message $message, array $thinkingBlocks = $message->toolCalls ->whereNotNull('reasoningId') ->unique('reasoningId') - ->map(fn ($toolCall) => [ + ->map(fn ($toolCall): array => [ 'type' => 'thinking', 'thinking' => is_array($toolCall->reasoningSummary) ? implode("\n", array_column($toolCall->reasoningSummary, 'text')) @@ -138,7 +138,7 @@ protected function mapToolResultMessage(ToolResultMessage|Message $message, arra */ protected function ensureToolInputIsObject(array $content): array { - return array_map(function (array $block) { + return array_map(function (array $block): array { if (in_array($block['type'] ?? '', ['tool_use', 'server_tool_use'], true)) { $block['input'] = (object) ($block['input'] ?? []); } diff --git a/src/Gateway/Anthropic/Concerns/MapsTools.php b/src/Gateway/Anthropic/Concerns/MapsTools.php index 2051abb37..07b34c280 100644 --- a/src/Gateway/Anthropic/Concerns/MapsTools.php +++ b/src/Gateway/Anthropic/Concerns/MapsTools.php @@ -66,7 +66,7 @@ protected function mapProviderTool(ProviderTool $tool, Provider $provider): arra return match (true) { $tool instanceof WebFetch => $this->mapWebFetchTool($tool, $provider), $tool instanceof WebSearch => $this->mapWebSearchTool($tool, $provider), - default => throw new LogicException('Provider tool ['.get_class($tool).'] is not supported by Anthropic.'), + default => throw new LogicException('Provider tool ['.$tool::class.'] is not supported by Anthropic.'), }; } diff --git a/src/Gateway/Anthropic/Concerns/ParsesTextResponses.php b/src/Gateway/Anthropic/Concerns/ParsesTextResponses.php index e354fc4df..825480276 100644 --- a/src/Gateway/Anthropic/Concerns/ParsesTextResponses.php +++ b/src/Gateway/Anthropic/Concerns/ParsesTextResponses.php @@ -66,7 +66,7 @@ protected function buildStepResponse( $toolCalls = $this->extractToolCalls($content); $citations = $this->extractCitations($content); - $realToolCalls = array_values(array_filter($toolCalls, fn (ToolCall $tc) => $tc->name !== 'output_structured_data')); + $realToolCalls = array_values(array_filter($toolCalls, fn (ToolCall $tc): bool => $tc->name !== 'output_structured_data')); $hasStructuredToolCall = count($realToolCalls) < count($toolCalls); $structuredData = null; @@ -80,7 +80,7 @@ protected function buildStepResponse( } // If the only tool call was the synthetic structured output, this is really a stop. - if ($finishReason === FinishReason::ToolCalls && empty($realToolCalls)) { + if ($finishReason === FinishReason::ToolCalls && $realToolCalls === []) { $finishReason = FinishReason::Stop; } @@ -100,7 +100,7 @@ protected function buildStepResponse( */ protected function extractText(array $content): string { - $textBlocks = array_filter($content, fn (array $block) => ($block['type'] ?? '') === 'text'); + $textBlocks = array_filter($content, fn (array $block): bool => ($block['type'] ?? '') === 'text'); return implode('', array_column($textBlocks, 'text')); } @@ -112,9 +112,9 @@ protected function extractText(array $content): string */ protected function extractToolCalls(array $content): array { - $toolUseBlocks = array_filter($content, fn (array $block) => ($block['type'] ?? '') === 'tool_use'); + $toolUseBlocks = array_filter($content, fn (array $block): bool => ($block['type'] ?? '') === 'tool_use'); - return array_values(array_map(fn (array $block) => new ToolCall( + return array_values(array_map(fn (array $block): ToolCall => new ToolCall( $block['id'] ?? '', $block['name'] ?? '', $block['input'] ?? [], diff --git a/src/Gateway/AzureOpenAi/AzureOpenAiFileGateway.php b/src/Gateway/AzureOpenAi/AzureOpenAiFileGateway.php index 82d816ca8..551a11cc6 100644 --- a/src/Gateway/AzureOpenAi/AzureOpenAiFileGateway.php +++ b/src/Gateway/AzureOpenAi/AzureOpenAiFileGateway.php @@ -12,6 +12,7 @@ class AzureOpenAiFileGateway extends OpenAiFileGateway /** * Get the default purpose to use when a file does not specify one. */ + #[\Override] protected function defaultPurpose(): string { return 'assistants'; @@ -20,6 +21,7 @@ protected function defaultPurpose(): string /** * Get the provider key used to resolve file upload options. */ + #[\Override] protected function providerOptionsKey(): Lab { return Lab::Azure; diff --git a/src/Gateway/AzureOpenAi/AzureOpenAiGateway.php b/src/Gateway/AzureOpenAi/AzureOpenAiGateway.php index bb655bba8..e22e6b11d 100644 --- a/src/Gateway/AzureOpenAi/AzureOpenAiGateway.php +++ b/src/Gateway/AzureOpenAi/AzureOpenAiGateway.php @@ -106,7 +106,7 @@ public function generateImage( $data = $response->json(); return new ImageResponse( - collect($data['data'] ?? [])->map(fn (array $image) => new GeneratedImage( + collect($data['data'] ?? [])->map(fn (array $image): GeneratedImage => new GeneratedImage( $image['b64_json'] ?? '', 'image/png', )), diff --git a/src/Gateway/Bedrock/BedrockImageGateway.php b/src/Gateway/Bedrock/BedrockImageGateway.php index 2045361a7..5120e91f8 100644 --- a/src/Gateway/Bedrock/BedrockImageGateway.php +++ b/src/Gateway/Bedrock/BedrockImageGateway.php @@ -48,11 +48,11 @@ public function generateImage( 'body' => json_encode($this->prepareImageRequestBody($model, $prompt, $size, $options)), ]), ); - } catch (Throwable $e) { - throw BedrockException::toAiException($e, $provider->name(), $model); + } catch (Throwable $throwable) { + throw BedrockException::toAiException($throwable, $provider->name(), $model); } - $result = json_decode($response->get('body')->getContents(), true); + $result = json_decode((string) $response->get('body')->getContents(), true); return new ImageResponse( $this->parseImageResponse($model, $result), @@ -111,7 +111,7 @@ protected function parseImageResponse(string $model, array $result): Collection || str_starts_with($model, 'amazon.titan-image') || str_starts_with($model, 'amazon.nova-canvas')) { return (new Collection($result['images'] ?? [])) - ->map(fn ($image) => new GeneratedImage($image ?? '', 'image/png')); + ->map(fn ($image): GeneratedImage => new GeneratedImage($image ?? '', 'image/png')); } return new Collection; diff --git a/src/Gateway/Bedrock/BedrockTextGateway.php b/src/Gateway/Bedrock/BedrockTextGateway.php index 27ed9089a..7e272cb0e 100644 --- a/src/Gateway/Bedrock/BedrockTextGateway.php +++ b/src/Gateway/Bedrock/BedrockTextGateway.php @@ -84,8 +84,8 @@ public function generateTextStep( ); $result = $response->toArray(); - } catch (Throwable $e) { - throw BedrockException::toAiException($e, $provider->name(), $model); + } catch (Throwable $throwable) { + throw BedrockException::toAiException($throwable, $provider->name(), $model); } return $this->parseTextResponse($result, $provider, $model, filled($schema)); @@ -115,8 +115,8 @@ public function generateStreamStep( $provider->name(), fn () => $client->converseStream($parameters), ); - } catch (Throwable $e) { - throw BedrockException::toAiException($e, $provider->name(), $model); + } catch (Throwable $throwable) { + throw BedrockException::toAiException($throwable, $provider->name(), $model); } return yield from $this->processTextStream($invocationId, $provider, $model, $response['stream'], filled($schema)); @@ -137,7 +137,7 @@ protected function buildStepBody( ): array { $conversationMessages = $this->formatMessages($messages); $schemaTools = $schema ? $this->buildSchemaTools($schema, $tools) : null; - $formattedTools = $schemaTools === null && ! empty($tools) ? $this->formatTools($tools) : null; + $formattedTools = $schemaTools === null && $tools !== [] ? $this->formatTools($tools) : null; return $this->buildConverseParameters( $model, @@ -145,7 +145,7 @@ protected function buildStepBody( $conversationMessages, $schemaTools, $formattedTools, - empty($tools), + $tools === [], $options, isFinalStep: $stepContext->isFinalStep, ); @@ -196,7 +196,7 @@ protected function parseTextResponse(array $result, TextProvider $provider, stri $finishReason = $this->extractFinishReason($result); - if (empty($toolCalls) && $structured && $finishReason === FinishReason::ToolCalls) { + if ($toolCalls === [] && $structured && $finishReason === FinishReason::ToolCalls) { $finishReason = FinishReason::Stop; } @@ -250,7 +250,7 @@ protected function processTextStream( $hasReasoningBlocks = false; $stopReason = 'stop'; - $emitTextStart = function () use (&$textId, $invocationId, $timestamp) { + $emitTextStart = function () use (&$textId, $invocationId, $timestamp): ?\Laravel\Ai\Streaming\Events\StreamEvent { if ($textId !== '') { return null; } @@ -264,7 +264,7 @@ protected function processTextStream( ))->withInvocationId($invocationId); }; - $emitReasoningStart = function () use (&$reasoningId, $invocationId, $timestamp) { + $emitReasoningStart = function () use (&$reasoningId, $invocationId, $timestamp): ?\Laravel\Ai\Streaming\Events\StreamEvent { if ($reasoningId !== '') { return null; } @@ -303,7 +303,7 @@ protected function processTextStream( $currentBlockType = 'text'; if ($delta['text'] !== '') { - if ($emittedEvent = $emitTextStart()) { + if (($emittedEvent = $emitTextStart()) instanceof StreamEvent) { yield $emittedEvent; } @@ -321,7 +321,7 @@ protected function processTextStream( $currentBlockType = 'reasoning'; $hasReasoningBlocks = true; - if ($emittedEvent = $emitReasoningStart()) { + if (($emittedEvent = $emitReasoningStart()) instanceof StreamEvent) { yield $emittedEvent; } @@ -337,7 +337,7 @@ protected function processTextStream( $currentBlockType = 'reasoning'; $hasReasoningBlocks = true; - if ($emittedEvent = $emitReasoningStart()) { + if (($emittedEvent = $emitReasoningStart()) instanceof StreamEvent) { yield $emittedEvent; } @@ -346,7 +346,7 @@ protected function processTextStream( $currentBlockType = 'reasoning'; $hasReasoningBlocks = true; - if ($emittedEvent = $emitReasoningStart()) { + if (($emittedEvent = $emitReasoningStart()) instanceof StreamEvent) { yield $emittedEvent; } @@ -464,7 +464,7 @@ protected function processTextStream( $finishReason = $this->extractFinishReason(['stopReason' => $stopReason]); - if (empty($toolCalls) && $structured && $finishReason === FinishReason::ToolCalls) { + if ($toolCalls === [] && $structured && $finishReason === FinishReason::ToolCalls) { $finishReason = FinishReason::Stop; } @@ -523,7 +523,7 @@ public function generateEmbeddings( ]), ); - $result = json_decode($response->get('body')->getContents(), true); + $result = json_decode((string) $response->get('body')->getContents(), true); } catch (Throwable $e) { throw BedrockException::toAiException($e, $provider->name(), $model); } @@ -569,14 +569,14 @@ protected function generateCohereEmbeddings( ]), ); - $result = json_decode($response->get('body')->getContents(), true); - } catch (Throwable $e) { - throw BedrockException::toAiException($e, $provider->name(), $model); + $result = json_decode((string) $response->get('body')->getContents(), true); + } catch (Throwable $throwable) { + throw BedrockException::toAiException($throwable, $provider->name(), $model); } $embeddings = array_values(array_filter( $result['embeddings'] ?? [], - fn ($vector) => is_array($vector), + is_array(...), )); return new EmbeddingsResponse( @@ -593,7 +593,7 @@ protected function generateCohereEmbeddings( */ protected function resolveMaxSteps(array $tools, ?TextGenerationOptions $options): int { - if (empty($tools)) { + if ($tools === []) { return 1; } @@ -648,14 +648,14 @@ protected function buildConverseParameters( $inferenceConfig = $this->buildInferenceConfig($options); - if (! empty($inferenceConfig)) { + if ($inferenceConfig !== []) { $parameters['inferenceConfig'] = $inferenceConfig; } $providerOptions = $options?->providerOptions(Lab::Bedrock); if (! empty($providerOptions)) { - $parameters = array_merge($parameters, $providerOptions); + return array_merge($parameters, $providerOptions); } return $parameters; @@ -666,7 +666,7 @@ protected function buildConverseParameters( */ protected function buildInferenceConfig(?TextGenerationOptions $options): array { - if ($options === null) { + if (! $options instanceof TextGenerationOptions) { return []; } @@ -698,7 +698,7 @@ protected function buildAssistantConversationMessage(string $text, array $toolCa */ protected function ensureToolInputIsObject(array $content): array { - return array_map(function (array $block) { + return array_map(function (array $block): array { if (isset($block['toolUse'])) { $block['toolUse']['input'] = (object) ($block['toolUse']['input'] ?? []); } @@ -716,7 +716,7 @@ protected function buildToolResultConversationMessage(array $toolResults): array { return [ 'role' => 'user', - 'content' => array_map(fn (ToolResult $toolResult) => [ + 'content' => array_map(fn (ToolResult $toolResult): array => [ 'toolResult' => [ 'toolUseId' => $toolResult->id, 'content' => [ @@ -776,7 +776,7 @@ protected function buildToolConfig(?array $schemaTools, ?array $formattedTools, */ protected function formatMessages(array $messages): array { - return (new Collection($messages))->map(fn ($message) => match (true) { + return (new Collection($messages))->map(fn (\Laravel\Ai\Messages\AssistantMessage|\Laravel\Ai\Messages\ToolResultMessage|\Laravel\Ai\Messages\UserMessage|\Laravel\Ai\Messages\Message|array $message): array => match (true) { $message instanceof AssistantMessage => $this->formatAssistantMessage($message), $message instanceof ToolResultMessage => $this->formatToolResultMessage($message), $message instanceof UserMessage => $this->formatUserMessage($message), @@ -883,8 +883,8 @@ protected function formatArrayMessage(array $message): array protected function formatTools(array $tools): array { return (new Collection($tools)) - ->filter(fn ($tool) => $tool instanceof Tool) - ->map(fn (Tool $tool) => [ + ->filter(fn ($tool): bool => $tool instanceof Tool) + ->map(fn (Tool $tool): array => [ 'toolSpec' => [ 'name' => ToolNameResolver::resolve($tool), 'description' => (string) $tool->description(), diff --git a/src/Gateway/Bedrock/Concerns/MapsAttachments.php b/src/Gateway/Bedrock/Concerns/MapsAttachments.php index 58ad33f93..7aa53b78f 100644 --- a/src/Gateway/Bedrock/Concerns/MapsAttachments.php +++ b/src/Gateway/Bedrock/Concerns/MapsAttachments.php @@ -28,28 +28,26 @@ trait MapsAttachments */ protected function mapAttachments(Collection $attachments): array { - return $attachments->map(function (File|UploadedFile $attachment) { - return match (true) { - $attachment instanceof Base64Document, - $attachment instanceof LocalDocument, - $attachment instanceof S3Document, - $attachment instanceof StoredDocument => $this->buildDocumentBlock($attachment), - $attachment instanceof Base64Image => $this->buildImageBlock($attachment, $attachment->content()), - $attachment instanceof LocalImage => $this->buildImageBlock($attachment, file_get_contents($attachment->path)), - $attachment instanceof StoredImage => $this->buildImageBlock( - $attachment, - Storage::disk($attachment->disk)->get($attachment->path), - ), - $attachment instanceof RemoteDocument, - $attachment instanceof RemoteImage => throw new InvalidArgumentException( - 'Remote attachments are not supported by Bedrock; download the file and pass it as a Base64, Local, or Stored attachment.' - ), - $attachment instanceof ProviderDocument, - $attachment instanceof ProviderImage => throw new InvalidArgumentException( - 'Provider-stored attachments are not supported by Bedrock.' - ), - default => throw new InvalidArgumentException('Unsupported attachment type ['.get_class($attachment).'].'), - }; + return $attachments->map(fn (File|UploadedFile $attachment) => match (true) { + $attachment instanceof Base64Document, + $attachment instanceof LocalDocument, + $attachment instanceof S3Document, + $attachment instanceof StoredDocument => $this->buildDocumentBlock($attachment), + $attachment instanceof Base64Image => $this->buildImageBlock($attachment, $attachment->content()), + $attachment instanceof LocalImage => $this->buildImageBlock($attachment, file_get_contents($attachment->path)), + $attachment instanceof StoredImage => $this->buildImageBlock( + $attachment, + Storage::disk($attachment->disk)->get($attachment->path), + ), + $attachment instanceof RemoteDocument, + $attachment instanceof RemoteImage => throw new InvalidArgumentException( + 'Remote attachments are not supported by Bedrock; download the file and pass it as a Base64, Local, or Stored attachment.' + ), + $attachment instanceof ProviderDocument, + $attachment instanceof ProviderImage => throw new InvalidArgumentException( + 'Provider-stored attachments are not supported by Bedrock.' + ), + default => throw new InvalidArgumentException('Unsupported attachment type ['.$attachment::class.'].'), })->all(); } @@ -99,7 +97,7 @@ protected function getDocumentFormat(Document $document): ?string { $mime = strtolower(trim(strtok($document->mimeType() ?? '', ';'))); - if (! $mime) { + if ($mime === '' || $mime === '0') { return null; } @@ -129,7 +127,7 @@ protected function getDocumentName(Document $document): string $name = pathinfo($name, PATHINFO_FILENAME) ?: $name; $name = preg_replace('/[^A-Za-z0-9\-\(\)\[\] ]+/', '-', $name); - return trim(preg_replace('/\s+/', ' ', $name)) ?: 'document'; + return trim((string) preg_replace('/\s+/', ' ', (string) $name)) ?: 'document'; } /** diff --git a/src/Gateway/CohereGateway.php b/src/Gateway/CohereGateway.php index 31e2219c2..b36d6879e 100644 --- a/src/Gateway/CohereGateway.php +++ b/src/Gateway/CohereGateway.php @@ -81,7 +81,7 @@ public function rerank( $data = $response->json(); - $results = (new Collection($data['results']))->map(fn (array $result) => new RankedDocument( + $results = (new Collection($data['results']))->map(fn (array $result): RankedDocument => new RankedDocument( index: $result['index'], document: $documents[$result['index']], score: $result['relevance_score'], diff --git a/src/Gateway/Concerns/DecodesStructuredOutput.php b/src/Gateway/Concerns/DecodesStructuredOutput.php index c9a593782..ce0e5fb5e 100644 --- a/src/Gateway/Concerns/DecodesStructuredOutput.php +++ b/src/Gateway/Concerns/DecodesStructuredOutput.php @@ -15,7 +15,7 @@ protected function decodeStructuredOutput(?string $text): array $payload = $this->stripJsonCodeFence($text); - $decoded = json_decode($payload, true); + $decoded = json_decode((string) $payload, true); return is_array($decoded) ? $decoded : []; } diff --git a/src/Gateway/Concerns/HandlesFailoverErrors.php b/src/Gateway/Concerns/HandlesFailoverErrors.php index 61c9786e2..ee7c6c8df 100644 --- a/src/Gateway/Concerns/HandlesFailoverErrors.php +++ b/src/Gateway/Concerns/HandlesFailoverErrors.php @@ -22,42 +22,42 @@ protected function withErrorHandling(string $providerName, Closure $callback): m { try { return $callback(); - } catch (RequestException $e) { - if ($e->response !== null) { - $status = $e->response->status(); + } catch (RequestException $requestException) { + if ($requestException->response !== null) { + $status = $requestException->response->status(); if ($status === 429) { throw RateLimitedException::forProvider( - $providerName, $e->getCode(), $e + $providerName, $requestException->getCode(), $requestException ); } if ($status === 402) { throw InsufficientCreditsException::forProvider( - $providerName, $e->getCode(), $e + $providerName, $requestException->getCode(), $requestException ); } if (in_array($status, $this->overloadedStatusCodes())) { throw ProviderOverloadedException::forProvider( - $providerName, $e->getCode(), $e + $providerName, $requestException->getCode(), $requestException ); } if ($patterns = $this->insufficientCreditPatterns()) { - $message = strtolower($e->response->json('error.message', '')); + $message = strtolower($requestException->response->json('error.message', '')); foreach ($patterns as $pattern) { - if (str_contains($message, $pattern)) { + if (str_contains($message, (string) $pattern)) { throw InsufficientCreditsException::forProvider( - $providerName, $e->getCode(), $e + $providerName, $requestException->getCode(), $requestException ); } } } } - throw $e; + throw $requestException; } } diff --git a/src/Gateway/Concerns/InvokesTools.php b/src/Gateway/Concerns/InvokesTools.php index 272848c05..8e42edb78 100644 --- a/src/Gateway/Concerns/InvokesTools.php +++ b/src/Gateway/Concerns/InvokesTools.php @@ -41,7 +41,7 @@ protected function executeTool(Tool $tool, array $arguments): string return (string) tap( $tool->handle(new Request($arguments)), - fn ($result) => call_user_func($callbacks['invoked'], $tool, $arguments, $result) + fn ($result): mixed => call_user_func($callbacks['invoked'], $tool, $arguments, $result) ); } finally { $this->popToolInvocationCallbacks(); @@ -67,8 +67,8 @@ protected function findTool(string $name, array $tools): ?Tool */ protected function initializeToolCallbacks(): void { - $this->invokingToolCallback ??= fn () => true; - $this->toolInvokedCallback ??= fn () => true; + $this->invokingToolCallback ??= fn (): true => true; + $this->toolInvokedCallback ??= fn (): true => true; } /** diff --git a/src/Gateway/Concerns/ParsesServerSentEvents.php b/src/Gateway/Concerns/ParsesServerSentEvents.php index 5c205547d..1846860c7 100644 --- a/src/Gateway/Concerns/ParsesServerSentEvents.php +++ b/src/Gateway/Concerns/ParsesServerSentEvents.php @@ -13,8 +13,10 @@ protected function parseServerSentEvents($streamBody): Generator { while (! $streamBody->eof()) { $line = trim($this->readLine($streamBody)); - - if ($line === '' || ! str_starts_with($line, 'data:')) { + if ($line === '') { + continue; + } + if (! str_starts_with($line, 'data:')) { continue; } diff --git a/src/Gateway/DeepSeek/Concerns/BuildsTextRequests.php b/src/Gateway/DeepSeek/Concerns/BuildsTextRequests.php index 80dc6384c..13eaf686b 100644 --- a/src/Gateway/DeepSeek/Concerns/BuildsTextRequests.php +++ b/src/Gateway/DeepSeek/Concerns/BuildsTextRequests.php @@ -7,6 +7,7 @@ use Laravel\Ai\Gateway\StepContext; use Laravel\Ai\Gateway\TextGenerationOptions; use Laravel\Ai\Providers\Provider; +use Laravel\Ai\ToolChoice; trait BuildsTextRequests { @@ -52,7 +53,7 @@ protected function buildTextRequestBody( $mappedTools = $this->mapTools($tools, $provider); if (filled($mappedTools)) { - $body['tool_choice'] = $options?->toolChoice + $body['tool_choice'] = $options?->toolChoice instanceof ToolChoice ? $this->mapToolChoice($options->toolChoice) : 'auto'; $body['tools'] = $mappedTools; @@ -75,7 +76,7 @@ protected function buildTextRequestBody( $providerOptions = $options?->providerOptions($provider->driver()); if (filled($providerOptions)) { - $body = array_merge($body, $providerOptions); + return array_merge($body, $providerOptions); } return $body; diff --git a/src/Gateway/DeepSeek/Concerns/HandlesTextStreaming.php b/src/Gateway/DeepSeek/Concerns/HandlesTextStreaming.php index ad887b393..3a4bbf62e 100644 --- a/src/Gateway/DeepSeek/Concerns/HandlesTextStreaming.php +++ b/src/Gateway/DeepSeek/Concerns/HandlesTextStreaming.php @@ -217,7 +217,7 @@ protected function processTextStream( */ protected function mapStreamToolCalls(array $toolCalls): array { - return array_map(fn (array $toolCall) => new ToolCall( + return array_map(fn (array $toolCall): ToolCall => new ToolCall( $toolCall['id'] ?? '', $toolCall['name'] ?? '', json_decode($toolCall['arguments'] ?? '{}', true) ?? [], diff --git a/src/Gateway/DeepSeek/Concerns/MapsAttachments.php b/src/Gateway/DeepSeek/Concerns/MapsAttachments.php index a54b8364a..a307198cb 100644 --- a/src/Gateway/DeepSeek/Concerns/MapsAttachments.php +++ b/src/Gateway/DeepSeek/Concerns/MapsAttachments.php @@ -19,10 +19,10 @@ trait MapsAttachments */ protected function mapAttachments(Collection $attachments): array { - return $attachments->map(function ($attachment) { + return $attachments->map(function ($attachment): array { if (! $attachment instanceof File && ! $attachment instanceof UploadedFile) { throw new InvalidArgumentException( - 'Unsupported attachment type ['.get_class($attachment).']' + 'Unsupported attachment type ['.$attachment::class.']' ); } @@ -42,7 +42,7 @@ protected function mapAttachments(Collection $attachments): array $attachment instanceof StoredImage => [ 'type' => 'image_url', 'image_url' => ['url' => 'data:'.($attachment->mimeType() ?? 'image/png').';base64,'.base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) )], ], $attachment instanceof UploadedFile && $this->isImage($attachment) => [ @@ -64,6 +64,7 @@ protected function isImage(UploadedFile $attachment): bool 'image/png', 'image/gif', 'image/webp', - ]); + ], + true); } } diff --git a/src/Gateway/DeepSeek/Concerns/ParsesTextResponses.php b/src/Gateway/DeepSeek/Concerns/ParsesTextResponses.php index f583e764e..bbf36c154 100644 --- a/src/Gateway/DeepSeek/Concerns/ParsesTextResponses.php +++ b/src/Gateway/DeepSeek/Concerns/ParsesTextResponses.php @@ -50,7 +50,7 @@ protected function parseTextResponse( $text = $message['content'] ?? ''; $rawToolCalls = $message['tool_calls'] ?? []; - $mappedToolCalls = array_map(fn (array $toolCall) => new ToolCall( + $mappedToolCalls = array_map(fn (array $toolCall): ToolCall => new ToolCall( $toolCall['id'] ?? '', $toolCall['function']['name'] ?? '', json_decode($toolCall['function']['arguments'] ?? '{}', true) ?? [], diff --git a/src/Gateway/ElevenLabsGateway.php b/src/Gateway/ElevenLabsGateway.php index a01463b03..0724f547b 100644 --- a/src/Gateway/ElevenLabsGateway.php +++ b/src/Gateway/ElevenLabsGateway.php @@ -81,7 +81,7 @@ public function generateTranscription( return new TranscriptionResponse( $response['text'], - (new Collection($segments))->map(function ($segment) { + (new Collection($segments))->map(function (array $segment) { if ($segment['type'] !== 'word') { return; } diff --git a/src/Gateway/FakeAudioGateway.php b/src/Gateway/FakeAudioGateway.php index df67e7508..3f145a3a2 100644 --- a/src/Gateway/FakeAudioGateway.php +++ b/src/Gateway/FakeAudioGateway.php @@ -47,7 +47,7 @@ protected function nextResponse(AudioProvider $provider, string $model, AudioPro return tap($this->marshalResponse( $response, $provider, $model, $prompt - ), fn () => $this->currentResponseIndex++); + ), fn (): int => $this->currentResponseIndex++); } /** diff --git a/src/Gateway/FakeEmbeddingGateway.php b/src/Gateway/FakeEmbeddingGateway.php index ec6532a89..7813a259d 100644 --- a/src/Gateway/FakeEmbeddingGateway.php +++ b/src/Gateway/FakeEmbeddingGateway.php @@ -54,7 +54,7 @@ protected function nextResponse( return tap($this->marshalResponse( $response, $provider, $model, $prompt - ), fn () => $this->currentResponseIndex++); + ), fn (): int => $this->currentResponseIndex++); } /** @@ -100,7 +100,7 @@ protected function marshalResponse( protected function generateFakeEmbeddings(int $count, int $dimensions): array { return array_map( - fn () => Embeddings::fakeEmbedding($dimensions), + fn (): array => Embeddings::fakeEmbedding($dimensions), range(1, $count) ); } diff --git a/src/Gateway/FakeFileGateway.php b/src/Gateway/FakeFileGateway.php index 36839af8b..3f516818f 100644 --- a/src/Gateway/FakeFileGateway.php +++ b/src/Gateway/FakeFileGateway.php @@ -40,7 +40,7 @@ protected function nextGetResponse(string $fileId): FileResponse return tap($this->marshalGetResponse( $response, $fileId - ), fn () => $this->currentResponseIndex++); + ), fn (): int => $this->currentResponseIndex++); } /** diff --git a/src/Gateway/FakeImageGateway.php b/src/Gateway/FakeImageGateway.php index 4414c0d3a..6c1914792 100644 --- a/src/Gateway/FakeImageGateway.php +++ b/src/Gateway/FakeImageGateway.php @@ -55,7 +55,7 @@ protected function nextResponse(ImageProvider $provider, string $model, ImagePro return tap($this->marshalResponse( $response, $provider, $model, $prompt - ), fn () => $this->currentResponseIndex++); + ), fn (): int => $this->currentResponseIndex++); } /** diff --git a/src/Gateway/FakeRerankingGateway.php b/src/Gateway/FakeRerankingGateway.php index c85b164f6..53637ad8b 100644 --- a/src/Gateway/FakeRerankingGateway.php +++ b/src/Gateway/FakeRerankingGateway.php @@ -53,7 +53,7 @@ protected function nextResponse( return tap($this->marshalResponse( $response, $provider, $model, $prompt - ), fn () => $this->currentResponseIndex++); + ), fn (): int => $this->currentResponseIndex++); } /** diff --git a/src/Gateway/FakeStoreGateway.php b/src/Gateway/FakeStoreGateway.php index 4879412d3..197a808d2 100644 --- a/src/Gateway/FakeStoreGateway.php +++ b/src/Gateway/FakeStoreGateway.php @@ -41,7 +41,7 @@ protected function nextGetResponse(StoreProvider $provider, string $storeId): St return tap($this->marshalGetResponse( $provider, $response, $storeId - ), fn () => $this->currentResponseIndex++); + ), fn (): int => $this->currentResponseIndex++); } /** diff --git a/src/Gateway/FakeTextGateway.php b/src/Gateway/FakeTextGateway.php index 58ef1891f..2776365ea 100644 --- a/src/Gateway/FakeTextGateway.php +++ b/src/Gateway/FakeTextGateway.php @@ -107,9 +107,7 @@ public function generateStreamStep( */ protected function nextStep(TextProvider $provider, string $model, array $messages, ?array $schema): StepResponse { - $message = (new Collection($messages))->last(function ($message) { - return $message instanceof UserMessage; - }); + $message = (new Collection($messages))->last(fn ($message): bool => $message instanceof UserMessage); /** @var UserMessage $message */ $response = $this->nextResponse( @@ -152,7 +150,7 @@ protected function nextResponse(TextProvider $provider, string $model, string $p return tap($this->marshalResponse( $response, $provider, $model, $prompt, $attachments, $schema - ), fn () => $this->currentResponseIndex++); + ), fn (): int => $this->currentResponseIndex++); } /** diff --git a/src/Gateway/FakeTranscriptionGateway.php b/src/Gateway/FakeTranscriptionGateway.php index 778ea7a83..03c2bc5c7 100644 --- a/src/Gateway/FakeTranscriptionGateway.php +++ b/src/Gateway/FakeTranscriptionGateway.php @@ -54,7 +54,7 @@ protected function nextResponse(TranscriptionProvider $provider, string $model, return tap($this->marshalResponse( $response, $provider, $model, $prompt - ), fn () => $this->currentResponseIndex++); + ), fn (): int => $this->currentResponseIndex++); } /** diff --git a/src/Gateway/Gemini/Concerns/BuildsTextRequests.php b/src/Gateway/Gemini/Concerns/BuildsTextRequests.php index 1b7f2b4c8..5ac6f0cad 100644 --- a/src/Gateway/Gemini/Concerns/BuildsTextRequests.php +++ b/src/Gateway/Gemini/Concerns/BuildsTextRequests.php @@ -66,7 +66,7 @@ private function assembleRequestBody( if (filled($tools)) { $body['tools'] = $this->mapTools($tools, $provider); - if ($options?->toolChoice) { + if ($options?->toolChoice instanceof ToolChoice) { $body['tool_config'] = [ 'function_calling_config' => $this->functionCallingConfig($options->toolChoice), ]; @@ -118,7 +118,7 @@ private function assembleRequestBody( */ protected function buildFunctionResponseParts(array $toolResults): array { - return array_values(array_map(function ($result) { + return array_values(array_map(function ($result): array { $functionResponse = [ 'name' => $result->name, 'response' => [ diff --git a/src/Gateway/Gemini/Concerns/MapsAttachments.php b/src/Gateway/Gemini/Concerns/MapsAttachments.php index 97ccce5f7..ba7ed3ffd 100644 --- a/src/Gateway/Gemini/Concerns/MapsAttachments.php +++ b/src/Gateway/Gemini/Concerns/MapsAttachments.php @@ -29,10 +29,10 @@ trait MapsAttachments */ protected function mapAttachments(Collection $attachments): array { - return $attachments->map(function ($attachment) { + return $attachments->map(function ($attachment): array { if (! $attachment instanceof File && ! $attachment instanceof UploadedFile) { throw new InvalidArgumentException( - 'Unsupported attachment type ['.get_class($attachment).']' + 'Unsupported attachment type ['.$attachment::class.']' ); } @@ -64,7 +64,7 @@ protected function mapAttachments(Collection $attachments): array 'inlineData' => [ 'mimeType' => $attachment->mimeType() ?? 'image/png', 'data' => base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) ), ], ], @@ -95,7 +95,7 @@ protected function mapAttachments(Collection $attachments): array 'inlineData' => [ 'mimeType' => $attachment->mimeType() ?? 'application/octet-stream', 'data' => base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) ), ], ], @@ -115,7 +115,7 @@ protected function mapAttachments(Collection $attachments): array 'inlineData' => [ 'mimeType' => $attachment->mimeType() ?? 'audio/mp3', 'data' => base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) ), ], ], @@ -131,7 +131,7 @@ protected function mapAttachments(Collection $attachments): array 'data' => base64_encode($attachment->get()), ], ], - default => throw new InvalidArgumentException('Unsupported attachment type ['.get_class($attachment).']'), + default => throw new InvalidArgumentException('Unsupported attachment type ['.$attachment::class.']'), }; })->all(); } diff --git a/src/Gateway/Gemini/Concerns/ParsesTextResponses.php b/src/Gateway/Gemini/Concerns/ParsesTextResponses.php index c59f2651b..54b92021b 100644 --- a/src/Gateway/Gemini/Concerns/ParsesTextResponses.php +++ b/src/Gateway/Gemini/Concerns/ParsesTextResponses.php @@ -99,7 +99,7 @@ protected function excludeThinkingParts(array $parts): array { return array_values(array_filter( $parts, - fn (array $part) => ! $this->isThinkingPart($part), + fn (array $part): bool => ! $this->isThinkingPart($part), )); } @@ -127,7 +127,7 @@ protected function extractRawToolCalls(array $parts): array return array_values( array_map( fn (array $part) => $part['functionCall'], - array_filter($parts, fn (array $part) => isset($part['functionCall'])) + array_filter($parts, fn (array $part): bool => isset($part['functionCall'])) ) ); } @@ -139,7 +139,7 @@ protected function extractRawToolCalls(array $parts): array */ protected function mapToolCalls(array $rawToolCalls): array { - return array_map(function (array $fc) { + return array_map(function (array $fc): ToolCall { $id = $fc['id'] ?? (string) Str::uuid7(); return new ToolCall( @@ -184,7 +184,7 @@ protected function extractCitations(array $data): Collection } } - foreach ($referencedIndices as $index => $_) { + foreach (array_keys($referencedIndices) as $index) { $web = $groundingChunks[$index]['web'] ?? []; if (isset($web['uri'])) { diff --git a/src/Gateway/Gemini/GeminiGateway.php b/src/Gateway/Gemini/GeminiGateway.php index 8d244cf18..4cd791065 100644 --- a/src/Gateway/Gemini/GeminiGateway.php +++ b/src/Gateway/Gemini/GeminiGateway.php @@ -146,9 +146,9 @@ public function generateImage( $data = $response->json(); $images = (new Collection($data['candidates'][0]['content']['parts'] ?? [])) - ->filter(fn ($part) => isset($part['inlineData'])) + ->filter(fn ($part): bool => isset($part['inlineData'])) ->values() - ->map(fn ($part) => new GeneratedImage( + ->map(fn ($part): GeneratedImage => new GeneratedImage( $part['inlineData']['data'], $part['inlineData']['mimeType'], )); @@ -171,7 +171,7 @@ public function generateEmbeddings( int $timeout = 30, array $providerOptions = [], ): EmbeddingsResponse { - $requests = array_map(fn (string $input) => array_merge($providerOptions, [ + $requests = array_map(fn (string $input): array => array_merge($providerOptions, [ 'model' => "models/{$model}", 'content' => ['parts' => [['text' => $input]]], 'output_dimensionality' => $dimensions, @@ -314,7 +314,7 @@ public function generateTranscription( $text = $data['transcript'] ?? ''; - $segments = (new Collection($data['segments'] ?? []))->map(fn ($seg) => new TranscriptionSegment( + $segments = (new Collection($data['segments'] ?? []))->map(fn ($seg): TranscriptionSegment => new TranscriptionSegment( $seg['text'], '', $this->timestampToSeconds($seg['start_time'] ?? '0:00'), @@ -342,7 +342,7 @@ public function generateTranscription( $usageMeta = $response->json('usageMetadata') ?? []; return new TranscriptionResponse( - trim($text), + trim((string) $text), $segments, new Usage( promptTokens: $usageMeta['promptTokenCount'] ?? 0, diff --git a/src/Gateway/Gemini/GeminiStoreGateway.php b/src/Gateway/Gemini/GeminiStoreGateway.php index a2015d553..b9849a2ab 100644 --- a/src/Gateway/Gemini/GeminiStoreGateway.php +++ b/src/Gateway/Gemini/GeminiStoreGateway.php @@ -81,10 +81,10 @@ public function addFile(StoreProvider $provider, string $storeId, string $fileId 'x-goog-api-key' => $provider->providerCredentials()['key'], ]))->post($this->baseUrl($provider)."/{$storeId}:importFile", array_filter([ 'fileName' => $fileId, - 'customMetadata' => ! empty($metadata) ? $this->formatMetadata($metadata) : null, + 'customMetadata' => $metadata === [] ? null : $this->formatMetadata($metadata), ]))->throw()); - return basename($response->json('name')); + return basename((string) $response->json('name')); } /** @@ -92,12 +92,10 @@ public function addFile(StoreProvider $provider, string $storeId, string $fileId */ protected function formatMetadata(array $metadata): array { - return (new Collection($metadata))->map(function ($value, $key) { - return match (true) { - is_numeric($value) => ['key' => $key, 'numericValue' => $value], - is_array($value) => ['key' => $key, 'stringListValue' => ['values' => $value]], - default => ['key' => $key, 'stringValue' => (string) $value], - }; + return (new Collection($metadata))->map(fn ($value, $key): array => match (true) { + is_numeric($value) => ['key' => $key, 'numericValue' => $value], + is_array($value) => ['key' => $key, 'stringListValue' => ['values' => $value]], + default => ['key' => $key, 'stringValue' => (string) $value], })->values()->all(); } diff --git a/src/Gateway/Groq/Concerns/BuildsTextRequests.php b/src/Gateway/Groq/Concerns/BuildsTextRequests.php index bcb8d4bf7..3d0cf6e3e 100644 --- a/src/Gateway/Groq/Concerns/BuildsTextRequests.php +++ b/src/Gateway/Groq/Concerns/BuildsTextRequests.php @@ -8,6 +8,7 @@ use Laravel\Ai\Gateway\TextGenerationOptions; use Laravel\Ai\ObjectSchema; use Laravel\Ai\Providers\Provider; +use Laravel\Ai\ToolChoice; trait BuildsTextRequests { @@ -49,7 +50,7 @@ protected function buildTextRequestBody( $mappedTools = $this->mapTools($tools, $provider); if (filled($mappedTools)) { - $body['tool_choice'] = $options?->toolChoice + $body['tool_choice'] = $options?->toolChoice instanceof ToolChoice ? $this->mapToolChoice($options->toolChoice) : 'auto'; $body['tools'] = $mappedTools; @@ -80,7 +81,7 @@ protected function buildTextRequestBody( $providerOptions = $options?->providerOptions($provider->driver()); if (filled($providerOptions)) { - $body = array_merge($body, $providerOptions); + return array_merge($body, $providerOptions); } return $body; diff --git a/src/Gateway/Groq/Concerns/HandlesTextStreaming.php b/src/Gateway/Groq/Concerns/HandlesTextStreaming.php index 766a74c31..4a43d9d71 100644 --- a/src/Gateway/Groq/Concerns/HandlesTextStreaming.php +++ b/src/Gateway/Groq/Concerns/HandlesTextStreaming.php @@ -158,7 +158,7 @@ protected function processTextStream( */ protected function mapStreamToolCalls(array $toolCalls): array { - return array_map(fn (array $toolCall) => new ToolCall( + return array_map(fn (array $toolCall): ToolCall => new ToolCall( $toolCall['id'] ?? '', $toolCall['name'] ?? '', json_decode($toolCall['arguments'] ?? '{}', true) ?? [], diff --git a/src/Gateway/Groq/Concerns/MapsAttachments.php b/src/Gateway/Groq/Concerns/MapsAttachments.php index 0074bd82a..7340b7c49 100644 --- a/src/Gateway/Groq/Concerns/MapsAttachments.php +++ b/src/Gateway/Groq/Concerns/MapsAttachments.php @@ -19,10 +19,10 @@ trait MapsAttachments */ protected function mapAttachments(Collection $attachments): array { - return $attachments->map(function ($attachment) { + return $attachments->map(function ($attachment): array { if (! $attachment instanceof File && ! $attachment instanceof UploadedFile) { throw new InvalidArgumentException( - 'Unsupported attachment type ['.get_class($attachment).']' + 'Unsupported attachment type ['.$attachment::class.']' ); } @@ -42,7 +42,7 @@ protected function mapAttachments(Collection $attachments): array $attachment instanceof StoredImage => [ 'type' => 'image_url', 'image_url' => ['url' => 'data:'.($attachment->mimeType() ?? 'image/png').';base64,'.base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) )], ], $attachment instanceof UploadedFile && $this->isImage($attachment) => [ @@ -64,6 +64,7 @@ protected function isImage(UploadedFile $attachment): bool 'image/png', 'image/gif', 'image/webp', - ]); + ], + true); } } diff --git a/src/Gateway/Groq/Concerns/ParsesTextResponses.php b/src/Gateway/Groq/Concerns/ParsesTextResponses.php index 7ae2e0b36..f4ab28e3f 100644 --- a/src/Gateway/Groq/Concerns/ParsesTextResponses.php +++ b/src/Gateway/Groq/Concerns/ParsesTextResponses.php @@ -48,7 +48,7 @@ protected function parseTextResponse( $usage = $this->extractUsage($data); $finishReason = $this->extractFinishReason($choice); - $mappedToolCalls = array_map(fn (array $toolCall) => new ToolCall( + $mappedToolCalls = array_map(fn (array $toolCall): ToolCall => new ToolCall( $toolCall['id'] ?? '', $toolCall['function']['name'] ?? '', json_decode($toolCall['function']['arguments'] ?? '{}', true) ?? [], diff --git a/src/Gateway/JinaGateway.php b/src/Gateway/JinaGateway.php index d8534f672..9cb97b936 100644 --- a/src/Gateway/JinaGateway.php +++ b/src/Gateway/JinaGateway.php @@ -40,7 +40,7 @@ public function generateEmbeddings( $providerOptions, [ 'model' => $model, - 'input' => array_map(fn (string $text) => ['text' => $text], $inputs), + 'input' => array_map(fn (string $text): array => ['text' => $text], $inputs), 'dimensions' => $dimensions, ], )), @@ -81,7 +81,7 @@ public function rerank( $data = $response->json(); - $results = (new Collection($data['results']))->map(fn (array $result) => new RankedDocument( + $results = (new Collection($data['results']))->map(fn (array $result): RankedDocument => new RankedDocument( index: $result['index'], document: $documents[$result['index']], score: $result['relevance_score'], diff --git a/src/Gateway/Mistral/Concerns/BuildsTextRequests.php b/src/Gateway/Mistral/Concerns/BuildsTextRequests.php index 2046658ef..205ac4d51 100644 --- a/src/Gateway/Mistral/Concerns/BuildsTextRequests.php +++ b/src/Gateway/Mistral/Concerns/BuildsTextRequests.php @@ -6,6 +6,7 @@ use Laravel\Ai\Gateway\TextGenerationOptions; use Laravel\Ai\ObjectSchema; use Laravel\Ai\Providers\Provider; +use Laravel\Ai\ToolChoice; trait BuildsTextRequests { @@ -43,7 +44,7 @@ protected function applyTextOptions( $mappedTools = $this->mapTools($tools, $provider); if (filled($mappedTools)) { - $body['tool_choice'] = $options?->toolChoice + $body['tool_choice'] = $options?->toolChoice instanceof ToolChoice ? $this->mapToolChoice($options->toolChoice) : 'auto'; $body['tools'] = $mappedTools; @@ -66,7 +67,7 @@ protected function applyTextOptions( $providerOptions = $options?->providerOptions($provider->driver()); if (filled($providerOptions)) { - $body = array_merge($body, $providerOptions); + return array_merge($body, $providerOptions); } return $body; diff --git a/src/Gateway/Mistral/Concerns/MapsAttachments.php b/src/Gateway/Mistral/Concerns/MapsAttachments.php index 61855ee3b..f11d71478 100644 --- a/src/Gateway/Mistral/Concerns/MapsAttachments.php +++ b/src/Gateway/Mistral/Concerns/MapsAttachments.php @@ -20,10 +20,10 @@ trait MapsAttachments */ protected function mapAttachments(Collection $attachments): array { - return $attachments->map(function ($attachment) { + return $attachments->map(function ($attachment): array { if (! $attachment instanceof File && ! $attachment instanceof UploadedFile) { throw new InvalidArgumentException( - 'Unsupported attachment type ['.get_class($attachment).']' + 'Unsupported attachment type ['.$attachment::class.']' ); } @@ -43,7 +43,7 @@ protected function mapAttachments(Collection $attachments): array $attachment instanceof StoredImage => [ 'type' => 'image_url', 'image_url' => ['url' => 'data:'.($attachment->mimeType() ?? 'image/png').';base64,'.base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) )], ], $attachment instanceof UploadedFile && $this->isImage($attachment) => [ @@ -56,7 +56,7 @@ protected function mapAttachments(Collection $attachments): array 'document_name' => $attachment->name ?? basename($attachment->url), ], default => throw new InvalidArgumentException( - 'Mistral only supports image attachments and remote document URLs. Unsupported attachment type ['.get_class($attachment).'].' + 'Mistral only supports image attachments and remote document URLs. Unsupported attachment type ['.$attachment::class.'].' ), }; })->all(); @@ -72,6 +72,7 @@ protected function isImage(UploadedFile $attachment): bool 'image/png', 'image/gif', 'image/webp', - ]); + ], + true); } } diff --git a/src/Gateway/Mistral/Concerns/ParsesTextResponses.php b/src/Gateway/Mistral/Concerns/ParsesTextResponses.php index ab795ae00..8b7e437b8 100644 --- a/src/Gateway/Mistral/Concerns/ParsesTextResponses.php +++ b/src/Gateway/Mistral/Concerns/ParsesTextResponses.php @@ -46,7 +46,7 @@ protected function parseTextResponse( $text = $message['content'] ?? ''; $rawToolCalls = $message['tool_calls'] ?? []; - $toolCalls = array_map(fn (array $toolCall) => new ToolCall( + $toolCalls = array_map(fn (array $toolCall): ToolCall => new ToolCall( $toolCall['id'] ?? '', $toolCall['function']['name'] ?? '', json_decode($toolCall['function']['arguments'] ?? '{}', true) ?? [], diff --git a/src/Gateway/Mistral/MistralGateway.php b/src/Gateway/Mistral/MistralGateway.php index 1fc65f61e..87ac2ba4d 100644 --- a/src/Gateway/Mistral/MistralGateway.php +++ b/src/Gateway/Mistral/MistralGateway.php @@ -118,7 +118,7 @@ public function generateTranscription( return new TranscriptionResponse( $data['text'] ?? '', - collect($data['segments'] ?? [])->map(fn (array $segment) => new TranscriptionSegment( + collect($data['segments'] ?? [])->map(fn (array $segment): TranscriptionSegment => new TranscriptionSegment( $segment['text'] ?? '', $segment['speaker_id'] ?? '', $segment['start'] ?? 0, diff --git a/src/Gateway/Ollama/Concerns/CreatesOllamaClient.php b/src/Gateway/Ollama/Concerns/CreatesOllamaClient.php index 21387c2fc..39c2e8abd 100644 --- a/src/Gateway/Ollama/Concerns/CreatesOllamaClient.php +++ b/src/Gateway/Ollama/Concerns/CreatesOllamaClient.php @@ -20,7 +20,7 @@ protected function client(Provider $provider, ?int $timeout = null): PendingRequ $key = $provider->providerCredentials()['key'] ?? null; if (filled($key)) { - $client = $client->withToken($key); + return $client->withToken($key); } return $client; diff --git a/src/Gateway/Ollama/Concerns/HandlesTextStreaming.php b/src/Gateway/Ollama/Concerns/HandlesTextStreaming.php index 421b1ac31..83ea34882 100644 --- a/src/Gateway/Ollama/Concerns/HandlesTextStreaming.php +++ b/src/Gateway/Ollama/Concerns/HandlesTextStreaming.php @@ -152,11 +152,11 @@ protected function processTextStream( } if (filled($pendingToolCalls)) { - $toolCalls = array_map(function (array $toolCall) { + $toolCalls = array_map(function (array $toolCall): ToolCall { $arguments = $toolCall['arguments']; if ($toolCall['argumentsBuffer'] !== '') { - $decoded = json_decode($toolCall['argumentsBuffer'], true); + $decoded = json_decode((string) $toolCall['argumentsBuffer'], true); if (is_array($decoded)) { $arguments = array_replace($arguments, $decoded); diff --git a/src/Gateway/Ollama/Concerns/MapsAttachments.php b/src/Gateway/Ollama/Concerns/MapsAttachments.php index fc95a06de..6c59f6d28 100644 --- a/src/Gateway/Ollama/Concerns/MapsAttachments.php +++ b/src/Gateway/Ollama/Concerns/MapsAttachments.php @@ -19,10 +19,10 @@ trait MapsAttachments */ protected function mapAttachments(Collection $attachments): array { - return $attachments->map(function ($attachment) { + return $attachments->map(function ($attachment): string { if (! $attachment instanceof File && ! $attachment instanceof UploadedFile) { throw new InvalidArgumentException( - 'Unsupported attachment type ['.get_class($attachment).']' + 'Unsupported attachment type ['.$attachment::class.']' ); } @@ -30,7 +30,7 @@ protected function mapAttachments(Collection $attachments): array $attachment instanceof Base64Image => $attachment->base64, $attachment instanceof LocalImage => base64_encode(file_get_contents($attachment->path)), $attachment instanceof StoredImage => base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) ), $attachment instanceof UploadedFile && $this->isImage($attachment) => base64_encode($attachment->get()), $attachment instanceof RemoteImage => throw new InvalidArgumentException('Ollama does not support remote image URLs. Use a local or base64 image instead.'), @@ -49,6 +49,7 @@ protected function isImage(UploadedFile $attachment): bool 'image/png', 'image/gif', 'image/webp', - ]); + ], + true); } } diff --git a/src/Gateway/Ollama/Concerns/ParsesTextResponses.php b/src/Gateway/Ollama/Concerns/ParsesTextResponses.php index 2c92fc6c3..c9132e12a 100644 --- a/src/Gateway/Ollama/Concerns/ParsesTextResponses.php +++ b/src/Gateway/Ollama/Concerns/ParsesTextResponses.php @@ -45,7 +45,7 @@ protected function parseTextResponse( $text = $message['content'] ?? ''; $rawToolCalls = $message['tool_calls'] ?? []; - $mappedToolCalls = array_map(function (array $toolCall) { + $mappedToolCalls = array_map(function (array $toolCall): ToolCall { $id = $toolCall['id'] ?? (string) Str::uuid7(); return new ToolCall( diff --git a/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php b/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php index b6d871c10..14ce99abd 100644 --- a/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php +++ b/src/Gateway/OpenAi/Concerns/BuildsTextRequests.php @@ -65,7 +65,7 @@ protected function extractToolResultsInput(array $messages): array } return collect($lastMessage->toolResults) - ->map(fn ($toolResult) => [ + ->map(fn ($toolResult): array => [ 'type' => 'function_call_output', 'call_id' => $toolResult->resultId, 'output' => $this->serializeToolResultOutput($toolResult->result), @@ -84,7 +84,7 @@ protected function mergeSharedResponsesRequestOptions( Provider $provider, ): array { if (filled($tools)) { - $body['tool_choice'] = $options?->toolChoice + $body['tool_choice'] = $options?->toolChoice instanceof ToolChoice ? $this->mapToolChoice($options->toolChoice) : 'auto'; $body['tools'] = $this->mapTools($tools, $provider); diff --git a/src/Gateway/OpenAi/Concerns/HandlesTextGeneration.php b/src/Gateway/OpenAi/Concerns/HandlesTextGeneration.php index b13cc587e..ab07fe7d5 100644 --- a/src/Gateway/OpenAi/Concerns/HandlesTextGeneration.php +++ b/src/Gateway/OpenAi/Concerns/HandlesTextGeneration.php @@ -169,8 +169,8 @@ protected function processTextStream( } } - if (str_starts_with($type, 'response.') && str_contains($type, '_call.')) { - $parts = explode('.', $type, 3); + if (str_starts_with((string) $type, 'response.') && str_contains((string) $type, '_call.')) { + $parts = explode('.', (string) $type, 3); if (count($parts) === 3 && str_ends_with($parts[1], '_call')) { yield (new ProviderToolEvent( @@ -218,6 +218,7 @@ protected function processTextStream( break; } } + unset($call); continue; diff --git a/src/Gateway/OpenAi/Concerns/MapsAttachments.php b/src/Gateway/OpenAi/Concerns/MapsAttachments.php index 63e5b071b..9728c9246 100644 --- a/src/Gateway/OpenAi/Concerns/MapsAttachments.php +++ b/src/Gateway/OpenAi/Concerns/MapsAttachments.php @@ -30,10 +30,10 @@ protected function mapAttachments(Collection $attachments, Provider $provider): { $providerKey = Lab::tryFrom($provider->driver()) ?? $provider->driver(); - return $attachments->map(function ($attachment) use ($providerKey) { + return $attachments->map(function ($attachment) use ($providerKey): array { if (! $attachment instanceof File && ! $attachment instanceof UploadedFile) { throw new InvalidArgumentException( - 'Unsupported attachment type ['.get_class($attachment).']' + 'Unsupported attachment type ['.$attachment::class.']' ); } @@ -57,7 +57,7 @@ protected function mapAttachments(Collection $attachments, Provider $provider): $attachment instanceof StoredImage => [ 'type' => 'input_image', 'image_url' => 'data:'.($attachment->mimeType() ?? 'image/png').';base64,'.base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) ), ], $attachment instanceof ProviderDocument => array_filter([ @@ -82,7 +82,7 @@ protected function mapAttachments(Collection $attachments, Provider $provider): $attachment instanceof StoredDocument => [ 'type' => 'input_file', 'file_data' => 'data:'.($attachment->mimeType() ?? 'application/octet-stream').';base64,'.base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) ), 'filename' => $attachment->name() ?? $this->fallbackFilename($attachment->mimeType()), ], @@ -95,7 +95,7 @@ protected function mapAttachments(Collection $attachments, Provider $provider): 'file_data' => 'data:'.$attachment->getClientMimeType().';base64,'.base64_encode($attachment->get()), 'filename' => $attachment->getClientOriginalName(), ], - default => throw new InvalidArgumentException('Unsupported attachment type ['.get_class($attachment).']'), + default => throw new InvalidArgumentException('Unsupported attachment type ['.$attachment::class.']'), }; return $attachment instanceof HasProviderOptions @@ -114,7 +114,8 @@ protected function isImage(UploadedFile $attachment): bool 'image/png', 'image/gif', 'image/webp', - ]); + ], + true); } protected function fallbackFilename(?string $mimeType): string diff --git a/src/Gateway/OpenAi/OpenAiGateway.php b/src/Gateway/OpenAi/OpenAiGateway.php index 0ead186cf..613fccc24 100644 --- a/src/Gateway/OpenAi/OpenAiGateway.php +++ b/src/Gateway/OpenAi/OpenAiGateway.php @@ -77,7 +77,7 @@ public function generateImage( $data = $response->json(); return new ImageResponse( - collect($data['data'] ?? [])->map(fn (array $image) => new GeneratedImage( + collect($data['data'] ?? [])->map(fn (array $image): GeneratedImage => new GeneratedImage( $image['b64_json'] ?? '', 'image/png', )), @@ -127,7 +127,7 @@ protected function sendImageEditRequest( foreach ($attachments as $attachment) { if (! $attachment instanceof File && ! $attachment instanceof UploadedFile) { throw new InvalidArgumentException( - 'Unsupported attachment type ['.get_class($attachment).']' + 'Unsupported attachment type ['.$attachment::class.']' ); } @@ -135,7 +135,7 @@ protected function sendImageEditRequest( $attachment instanceof LocalImage => file_get_contents($attachment->path), $attachment instanceof StoredImage => Storage::disk($attachment->disk)->get($attachment->path), $attachment instanceof UploadedFile => $attachment->get(), - default => throw new InvalidArgumentException('Unsupported image attachment type ['.get_class($attachment).']'), + default => throw new InvalidArgumentException('Unsupported image attachment type ['.$attachment::class.']'), }; $request = $request->attach($field, $content, 'image.png'); @@ -226,7 +226,7 @@ public function generateTranscription( return new TranscriptionResponse( $data['text'] ?? '', - collect($data['segments'] ?? [])->map(fn (array $segment) => new TranscriptionSegment( + collect($data['segments'] ?? [])->map(fn (array $segment): TranscriptionSegment => new TranscriptionSegment( $segment['text'] ?? '', $segment['speaker'] ?? '', $segment['start'] ?? 0, diff --git a/src/Gateway/OpenAi/OpenAiStoreGateway.php b/src/Gateway/OpenAi/OpenAiStoreGateway.php index 5f0ac311d..7a6a65390 100644 --- a/src/Gateway/OpenAi/OpenAiStoreGateway.php +++ b/src/Gateway/OpenAi/OpenAiStoreGateway.php @@ -59,7 +59,7 @@ public function createStore( 'name' => $name, 'metadata' => filled($description) ? ['description' => $description] : null, 'file_ids' => $fileIds?->values()->all(), - 'expires_after' => $expiresWhenIdleFor ? [ + 'expires_after' => $expiresWhenIdleFor instanceof DateInterval ? [ 'anchor' => 'last_active_at', 'days' => $this->intervalToDays($expiresWhenIdleFor), ] : null, diff --git a/src/Gateway/OpenAiCompatible/Concerns/BuildsTextRequests.php b/src/Gateway/OpenAiCompatible/Concerns/BuildsTextRequests.php index c72ac4eec..f33165b33 100644 --- a/src/Gateway/OpenAiCompatible/Concerns/BuildsTextRequests.php +++ b/src/Gateway/OpenAiCompatible/Concerns/BuildsTextRequests.php @@ -7,6 +7,7 @@ use Laravel\Ai\Gateway\TextGenerationOptions; use Laravel\Ai\ObjectSchema; use Laravel\Ai\Providers\Provider; +use Laravel\Ai\ToolChoice; trait BuildsTextRequests { @@ -29,7 +30,7 @@ protected function buildStepBody( $mappedTools = $this->mapTools($tools, $provider); if (filled($mappedTools)) { - $body['tool_choice'] = $options?->toolChoice + $body['tool_choice'] = $options?->toolChoice instanceof ToolChoice ? $this->mapToolChoice($options->toolChoice) : 'auto'; $body['tools'] = $mappedTools; @@ -54,7 +55,7 @@ protected function buildStepBody( $providerOptions = $options?->providerOptions($provider->name()); if (filled($providerOptions)) { - $body = array_merge($body, $providerOptions); + return array_merge($body, $providerOptions); } return $body; diff --git a/src/Gateway/OpenAiCompatible/Concerns/CreatesOpenAiCompatibleClient.php b/src/Gateway/OpenAiCompatible/Concerns/CreatesOpenAiCompatibleClient.php index 4d481124f..6dd1ffbaf 100644 --- a/src/Gateway/OpenAiCompatible/Concerns/CreatesOpenAiCompatibleClient.php +++ b/src/Gateway/OpenAiCompatible/Concerns/CreatesOpenAiCompatibleClient.php @@ -38,6 +38,6 @@ protected function baseUrl(Provider $provider): string ); } - return rtrim($url, '/'); + return rtrim((string) $url, '/'); } } diff --git a/src/Gateway/OpenAiCompatible/Concerns/HandlesTextStreaming.php b/src/Gateway/OpenAiCompatible/Concerns/HandlesTextStreaming.php index b47e17643..e8834f481 100644 --- a/src/Gateway/OpenAiCompatible/Concerns/HandlesTextStreaming.php +++ b/src/Gateway/OpenAiCompatible/Concerns/HandlesTextStreaming.php @@ -158,7 +158,7 @@ protected function processTextStream( */ protected function mapStreamToolCalls(array $toolCalls): array { - return array_map(fn (array $toolCall) => new ToolCall( + return array_map(fn (array $toolCall): ToolCall => new ToolCall( $toolCall['id'] ?? '', $toolCall['name'] ?? '', json_decode($toolCall['arguments'] ?? '{}', true) ?? [], diff --git a/src/Gateway/OpenAiCompatible/Concerns/MapsAttachments.php b/src/Gateway/OpenAiCompatible/Concerns/MapsAttachments.php index 39587edc7..c74090fbf 100644 --- a/src/Gateway/OpenAiCompatible/Concerns/MapsAttachments.php +++ b/src/Gateway/OpenAiCompatible/Concerns/MapsAttachments.php @@ -19,10 +19,10 @@ trait MapsAttachments */ protected function mapAttachments(Collection $attachments): array { - return $attachments->map(function ($attachment) { + return $attachments->map(function ($attachment): array { if (! $attachment instanceof File && ! $attachment instanceof UploadedFile) { throw new InvalidArgumentException( - 'Unsupported attachment type ['.get_class($attachment).']' + 'Unsupported attachment type ['.$attachment::class.']' ); } @@ -42,7 +42,7 @@ protected function mapAttachments(Collection $attachments): array $attachment instanceof StoredImage => [ 'type' => 'image_url', 'image_url' => ['url' => 'data:'.($attachment->mimeType() ?? 'image/png').';base64,'.base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) )], ], $attachment instanceof UploadedFile && $this->isImage($attachment) => [ @@ -64,6 +64,7 @@ protected function isImage(UploadedFile $attachment): bool 'image/png', 'image/gif', 'image/webp', - ]); + ], + true); } } diff --git a/src/Gateway/OpenAiCompatible/Concerns/ParsesTextResponses.php b/src/Gateway/OpenAiCompatible/Concerns/ParsesTextResponses.php index 31580e4dc..27291ee0c 100644 --- a/src/Gateway/OpenAiCompatible/Concerns/ParsesTextResponses.php +++ b/src/Gateway/OpenAiCompatible/Concerns/ParsesTextResponses.php @@ -43,7 +43,7 @@ protected function parseTextResponse( $text = $message['content'] ?? ''; $rawToolCalls = $message['tool_calls'] ?? []; - $mappedToolCalls = array_map(fn (array $toolCall) => new ToolCall( + $mappedToolCalls = array_map(fn (array $toolCall): ToolCall => new ToolCall( $toolCall['id'] ?? '', $toolCall['function']['name'] ?? '', json_decode($toolCall['function']['arguments'] ?? '{}', true) ?? [], diff --git a/src/Gateway/OpenRouter/Concerns/BuildsTextRequests.php b/src/Gateway/OpenRouter/Concerns/BuildsTextRequests.php index e0d3a2d36..8d6adc245 100644 --- a/src/Gateway/OpenRouter/Concerns/BuildsTextRequests.php +++ b/src/Gateway/OpenRouter/Concerns/BuildsTextRequests.php @@ -7,6 +7,7 @@ use Laravel\Ai\Gateway\TextGenerationOptions; use Laravel\Ai\ObjectSchema; use Laravel\Ai\Providers\Provider; +use Laravel\Ai\ToolChoice; trait BuildsTextRequests { @@ -47,7 +48,7 @@ protected function buildTextRequestBody( $mappedTools = $this->mapTools($tools, $provider); if (filled($mappedTools)) { - $body['tool_choice'] = $options?->toolChoice + $body['tool_choice'] = $options?->toolChoice instanceof ToolChoice ? $this->mapToolChoice($options->toolChoice) : 'auto'; $body['tools'] = $mappedTools; @@ -70,7 +71,7 @@ protected function buildTextRequestBody( $providerOptions = $options?->providerOptions($provider->driver()); if (filled($providerOptions)) { - $body = array_merge($body, $providerOptions); + return array_merge($body, $providerOptions); } return $body; diff --git a/src/Gateway/OpenRouter/Concerns/MapsAttachments.php b/src/Gateway/OpenRouter/Concerns/MapsAttachments.php index 929e9c7a3..52d171fbf 100644 --- a/src/Gateway/OpenRouter/Concerns/MapsAttachments.php +++ b/src/Gateway/OpenRouter/Concerns/MapsAttachments.php @@ -23,10 +23,10 @@ trait MapsAttachments */ protected function mapAttachments(Collection $attachments): array { - return $attachments->map(function ($attachment) { + return $attachments->map(function ($attachment): array { if (! $attachment instanceof File && ! $attachment instanceof UploadedFile) { throw new InvalidArgumentException( - 'Unsupported attachment type ['.get_class($attachment).']' + 'Unsupported attachment type ['.$attachment::class.']' ); } @@ -46,7 +46,7 @@ protected function mapAttachments(Collection $attachments): array $attachment instanceof StoredImage => [ 'type' => 'image_url', 'image_url' => ['url' => 'data:'.($attachment->mimeType() ?? 'image/png').';base64,'.base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) )], ], $attachment instanceof Base64Document => [ @@ -75,7 +75,7 @@ protected function mapAttachments(Collection $attachments): array 'file' => array_filter([ 'filename' => $attachment->name(), 'file_data' => 'data:'.($attachment->mimeType() ?? 'application/octet-stream').';base64,'.base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) ), ]), ], @@ -90,7 +90,7 @@ protected function mapAttachments(Collection $attachments): array 'file_data' => 'data:'.$attachment->getClientMimeType().';base64,'.base64_encode($attachment->get()), ], ], - default => throw new InvalidArgumentException('Unsupported attachment type ['.get_class($attachment).']'), + default => throw new InvalidArgumentException('Unsupported attachment type ['.$attachment::class.']'), }; })->all(); } @@ -105,6 +105,7 @@ protected function isImage(UploadedFile $attachment): bool 'image/png', 'image/gif', 'image/webp', - ]); + ], + true); } } diff --git a/src/Gateway/OpenRouter/Concerns/ParsesTextResponses.php b/src/Gateway/OpenRouter/Concerns/ParsesTextResponses.php index d3c924234..60cb8508b 100644 --- a/src/Gateway/OpenRouter/Concerns/ParsesTextResponses.php +++ b/src/Gateway/OpenRouter/Concerns/ParsesTextResponses.php @@ -48,7 +48,7 @@ protected function parseTextResponse( $text = $message['content'] ?? ''; $citations = $this->extractCitations($message); - $toolCalls = array_map(fn (array $toolCall) => new ToolCall( + $toolCalls = array_map(fn (array $toolCall): ToolCall => new ToolCall( $toolCall['id'] ?? '', $toolCall['function']['name'] ?? '', json_decode($toolCall['function']['arguments'] ?? '{}', true) ?? [], diff --git a/src/Gateway/OpenRouter/OpenRouterGateway.php b/src/Gateway/OpenRouter/OpenRouterGateway.php index e7b9c3667..8527f7e8d 100644 --- a/src/Gateway/OpenRouter/OpenRouterGateway.php +++ b/src/Gateway/OpenRouter/OpenRouterGateway.php @@ -106,7 +106,7 @@ public function generateImage( $message = $data['choices'][0]['message'] ?? []; - $images = collect($message['images'] ?? [])->map(function (array $image) { + $images = collect($message['images'] ?? [])->map(function (array $image): ?GeneratedImage { $url = $image['image_url']['url'] ?? ''; if (preg_match('/^data:(image\/[\w+.-]+);base64,(.+)$/', $url, $matches)) { @@ -132,7 +132,7 @@ public function generateImage( */ protected function buildImageMessages(string $prompt, array $attachments): array { - if (empty($attachments)) { + if ($attachments === []) { return [['role' => 'user', 'content' => $prompt]]; } diff --git a/src/Gateway/StepResponse.php b/src/Gateway/StepResponse.php index f869bcddd..00f56ae5c 100644 --- a/src/Gateway/StepResponse.php +++ b/src/Gateway/StepResponse.php @@ -35,7 +35,7 @@ public function toArray(): array return [ 'text' => $this->text, 'structured' => $this->structured, - 'tool_calls' => array_map(fn (ToolCall $tc) => $tc->toArray(), $this->toolCalls), + 'tool_calls' => array_map(fn (ToolCall $tc): array => $tc->toArray(), $this->toolCalls), 'provider_content_blocks' => $this->providerContentBlocks, 'finish_reason' => $this->finishReason->value, 'usage' => $this->usage->toArray(), diff --git a/src/Gateway/TextGenerationLoop.php b/src/Gateway/TextGenerationLoop.php index dbfa0e6d8..0a647017b 100644 --- a/src/Gateway/TextGenerationLoop.php +++ b/src/Gateway/TextGenerationLoop.php @@ -239,7 +239,7 @@ protected function resolveMaxSteps(?TextGenerationOptions $options, array $tools return max(1, $options->maxSteps); } - return count($tools) > 0 ? (int) round(count($tools) * 1.5) : 5; + return $tools !== [] ? (int) round(count($tools) * 1.5) : 5; } /** @@ -263,10 +263,10 @@ protected function continuationToolResults(FinishReason $reason, array $toolCall */ protected function executeToolCalls(array $toolCalls, array $tools): array { - return array_map(function (ToolCall $toolCall) use ($tools) { + return array_map(function (ToolCall $toolCall) use ($tools): ToolResult { $tool = $this->findTool($toolCall->name, $tools); - if ($tool === null) { + if (! $tool instanceof Tool) { throw new NoSuchToolException($toolCall->name); } @@ -307,7 +307,7 @@ protected function buildFinalResponse( $finalStep = $steps->last(); $totalUsage = $steps->reduce( - fn (Usage $carry, Step $step) => $carry->add($step->usage), + fn (Usage $carry, Step $step): Usage => $carry->add($step->usage), new Usage, ); @@ -320,8 +320,8 @@ protected function buildFinalResponse( $totalUsage, $finalStep->meta, ))->withToolCallsAndResults( - toolCalls: $steps->flatMap(fn (Step $s) => $s->toolCalls), - toolResults: $steps->flatMap(fn (Step $s) => $s->toolResults), + toolCalls: $steps->flatMap(fn (Step $s): array => $s->toolCalls), + toolResults: $steps->flatMap(fn (Step $s): array => $s->toolResults), )->withSteps($steps); } diff --git a/src/Gateway/TextGenerationOptions.php b/src/Gateway/TextGenerationOptions.php index 89a0ba4e1..707415592 100644 --- a/src/Gateway/TextGenerationOptions.php +++ b/src/Gateway/TextGenerationOptions.php @@ -46,7 +46,7 @@ public function providerOptions(Lab|string $provider): ?array */ public function forStep(int $stepNumber): self { - if ($stepNumber === 0 || $this->toolChoice === null) { + if ($stepNumber === 0 || ! $this->toolChoice instanceof ToolChoice) { return $this; } @@ -60,7 +60,6 @@ public function forStep(int $stepNumber): self temperature: $this->temperature, agent: $this->agent, topP: $this->topP, - toolChoice: null, ); } @@ -100,7 +99,7 @@ private static function resolveToolChoice(Agent $agent, ReflectionClass $reflect $attributes = $reflection->getAttributes(ToolChoice::class); - return ! empty($attributes) ? $attributes[0]->newInstance() : null; + return $attributes === [] ? null : $attributes[0]->newInstance(); } /** @@ -124,6 +123,6 @@ private static function resolve(Agent $agent, ReflectionClass $reflection, strin $attributes = $reflection->getAttributes($attribute); - return ! empty($attributes) ? $attributes[0]->newInstance()->value : null; + return $attributes === [] ? null : $attributes[0]->newInstance()->value; } } diff --git a/src/Gateway/VoyageAi/VoyageAiGateway.php b/src/Gateway/VoyageAi/VoyageAiGateway.php index 4310381af..446425514 100644 --- a/src/Gateway/VoyageAi/VoyageAiGateway.php +++ b/src/Gateway/VoyageAi/VoyageAiGateway.php @@ -71,7 +71,7 @@ public function rerank( )->json(); return new RerankingResponse( - collect($data['data'])->map(fn (array $result) => new RankedDocument( + collect($data['data'])->map(fn (array $result): RankedDocument => new RankedDocument( index: $result['index'], document: $documents[$result['index']], score: $result['relevance_score'], diff --git a/src/Gateway/Xai/Concerns/BuildsTextRequests.php b/src/Gateway/Xai/Concerns/BuildsTextRequests.php index 1d8317a0d..255cd4521 100644 --- a/src/Gateway/Xai/Concerns/BuildsTextRequests.php +++ b/src/Gateway/Xai/Concerns/BuildsTextRequests.php @@ -83,7 +83,7 @@ protected function mergeSharedResponsesRequestOptions( Provider $provider, ): array { if (filled($tools)) { - $body['tool_choice'] = $options?->toolChoice + $body['tool_choice'] = $options?->toolChoice instanceof ToolChoice ? $this->mapToolChoice($options->toolChoice) : 'auto'; $body['tools'] = $this->mapTools($tools, $provider); @@ -105,7 +105,7 @@ protected function mergeSharedResponsesRequestOptions( $providerOptions = $options?->providerOptions($provider->driver()); if (filled($providerOptions)) { - $body = array_merge($body, $providerOptions); + return array_merge($body, $providerOptions); } return $body; diff --git a/src/Gateway/Xai/Concerns/HandlesTextStreaming.php b/src/Gateway/Xai/Concerns/HandlesTextStreaming.php index 74ae70abc..3fd0e4bcd 100644 --- a/src/Gateway/Xai/Concerns/HandlesTextStreaming.php +++ b/src/Gateway/Xai/Concerns/HandlesTextStreaming.php @@ -170,8 +170,8 @@ protected function processTextStream( } } - if (str_starts_with($type, 'response.') && str_contains($type, '_call.')) { - $parts = explode('.', $type, 3); + if (str_starts_with((string) $type, 'response.') && str_contains((string) $type, '_call.')) { + $parts = explode('.', (string) $type, 3); if (count($parts) === 3 && str_ends_with($parts[1], '_call')) { yield (new ProviderToolEvent( @@ -218,6 +218,7 @@ protected function processTextStream( break; } } + unset($call); continue; diff --git a/src/Gateway/Xai/Concerns/MapsAttachments.php b/src/Gateway/Xai/Concerns/MapsAttachments.php index 92567838d..61f869c93 100644 --- a/src/Gateway/Xai/Concerns/MapsAttachments.php +++ b/src/Gateway/Xai/Concerns/MapsAttachments.php @@ -24,10 +24,10 @@ trait MapsAttachments */ protected function mapAttachments(Collection $attachments): array { - return $attachments->map(function ($attachment) { + return $attachments->map(function ($attachment): array { if (! $attachment instanceof File && ! $attachment instanceof UploadedFile) { throw new InvalidArgumentException( - 'Unsupported attachment type ['.get_class($attachment).']' + 'Unsupported attachment type ['.$attachment::class.']' ); } @@ -47,7 +47,7 @@ protected function mapAttachments(Collection $attachments): array $attachment instanceof StoredImage => [ 'type' => 'input_image', 'image_url' => 'data:'.($attachment->mimeType() ?? 'image/png').';base64,'.base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) ), ], $attachment instanceof ProviderDocument => array_filter([ @@ -73,7 +73,7 @@ protected function mapAttachments(Collection $attachments): array $attachment instanceof StoredDocument => array_filter([ 'type' => 'input_file', 'file_data' => 'data:'.($attachment->mimeType() ?? 'application/octet-stream').';base64,'.base64_encode( - Storage::disk($attachment->disk)->get($attachment->path) + (string) Storage::disk($attachment->disk)->get($attachment->path) ), 'filename' => $attachment->name(), ]), @@ -86,7 +86,7 @@ protected function mapAttachments(Collection $attachments): array 'file_data' => 'data:'.$attachment->getClientMimeType().';base64,'.base64_encode($attachment->get()), 'filename' => $attachment->getClientOriginalName(), ], - default => throw new InvalidArgumentException('Unsupported attachment type ['.get_class($attachment).']'), + default => throw new InvalidArgumentException('Unsupported attachment type ['.$attachment::class.']'), }; })->all(); } @@ -101,6 +101,7 @@ protected function isImage(UploadedFile $attachment): bool 'image/png', 'image/gif', 'image/webp', - ]); + ], + true); } } diff --git a/src/Gateway/Xai/Concerns/MapsMessages.php b/src/Gateway/Xai/Concerns/MapsMessages.php index 3088f30bc..58979c049 100644 --- a/src/Gateway/Xai/Concerns/MapsMessages.php +++ b/src/Gateway/Xai/Concerns/MapsMessages.php @@ -65,7 +65,7 @@ protected function mapAssistantMessage(AssistantMessage|Message $message, array $reasoningBlocks = $message->toolCalls ->whereNotNull('reasoningId') ->unique('reasoningId') - ->map(fn ($toolCall) => [ + ->map(fn ($toolCall): array => [ 'type' => 'reasoning', 'id' => $toolCall->reasoningId, 'summary' => $toolCall->reasoningSummary ?? [], diff --git a/src/Jobs/BroadcastAgent.php b/src/Jobs/BroadcastAgent.php index 7f54ef29b..c979e47fc 100644 --- a/src/Jobs/BroadcastAgent.php +++ b/src/Jobs/BroadcastAgent.php @@ -17,7 +17,8 @@ class BroadcastAgent implements ShouldQueue { - use Concerns\InvokesQueuedResponseCallbacks, Queueable; + use Concerns\InvokesQueuedResponseCallbacks; + use Queueable; public int $tries = 1; @@ -47,14 +48,14 @@ public function handle(): void $without = WithoutBroadcasting::eventsFor($this->agent); $this->agent->stream($this->prompt, $this->attachments, $this->provider, $this->model) - ->each(function (StreamEvent $event) use ($without) { + ->each(function (StreamEvent $event) use ($without): void { if (WithoutBroadcasting::excludes($without, $event)) { return; } $event->withInvocationId($this->invocationId)->broadcastNow($this->channels); }) - ->then(function ($response) use (&$streamedResponse) { + ->then(function ($response) use (&$streamedResponse): void { $streamedResponse = $response; }); diff --git a/src/Jobs/GenerateAudio.php b/src/Jobs/GenerateAudio.php index f2c2d13d1..1788747a6 100644 --- a/src/Jobs/GenerateAudio.php +++ b/src/Jobs/GenerateAudio.php @@ -6,10 +6,12 @@ use Illuminate\Foundation\Queue\Queueable; use Laravel\Ai\Enums\Lab; use Laravel\Ai\PendingResponses\PendingAudioGeneration; +use Laravel\Ai\Responses\AudioResponse; class GenerateAudio implements ShouldQueue { - use Concerns\InvokesQueuedResponseCallbacks, Queueable; + use Concerns\InvokesQueuedResponseCallbacks; + use Queueable; /** * Create a new job instance. @@ -24,7 +26,7 @@ public function __construct( */ public function handle(): void { - $this->withCallbacks(fn () => $this->pendingAudio->generate( + $this->withCallbacks(fn (): AudioResponse => $this->pendingAudio->generate( $this->provider, $this->model, )); diff --git a/src/Jobs/GenerateEmbeddings.php b/src/Jobs/GenerateEmbeddings.php index 15338cfaa..23ad0eb9d 100644 --- a/src/Jobs/GenerateEmbeddings.php +++ b/src/Jobs/GenerateEmbeddings.php @@ -6,10 +6,12 @@ use Illuminate\Foundation\Queue\Queueable; use Laravel\Ai\Enums\Lab; use Laravel\Ai\PendingResponses\PendingEmbeddingsGeneration; +use Laravel\Ai\Responses\EmbeddingsResponse; class GenerateEmbeddings implements ShouldQueue { - use Concerns\InvokesQueuedResponseCallbacks, Queueable; + use Concerns\InvokesQueuedResponseCallbacks; + use Queueable; /** * Create a new job instance. @@ -24,7 +26,7 @@ public function __construct( */ public function handle(): void { - $this->withCallbacks(fn () => $this->pendingEmbeddings->generate( + $this->withCallbacks(fn (): EmbeddingsResponse => $this->pendingEmbeddings->generate( $this->provider, $this->model, )); diff --git a/src/Jobs/GenerateImage.php b/src/Jobs/GenerateImage.php index dab174334..53279ebe5 100644 --- a/src/Jobs/GenerateImage.php +++ b/src/Jobs/GenerateImage.php @@ -6,10 +6,12 @@ use Illuminate\Foundation\Queue\Queueable; use Laravel\Ai\Enums\Lab; use Laravel\Ai\PendingResponses\PendingImageGeneration; +use Laravel\Ai\Responses\ImageResponse; class GenerateImage implements ShouldQueue { - use Concerns\InvokesQueuedResponseCallbacks, Queueable; + use Concerns\InvokesQueuedResponseCallbacks; + use Queueable; /** * Create a new job instance. @@ -24,7 +26,7 @@ public function __construct( */ public function handle(): void { - $this->withCallbacks(fn () => $this->pendingImage->generate( + $this->withCallbacks(fn (): ImageResponse => $this->pendingImage->generate( $this->provider, $this->model, )); diff --git a/src/Jobs/GenerateTranscription.php b/src/Jobs/GenerateTranscription.php index 6c5502811..0d790aa43 100644 --- a/src/Jobs/GenerateTranscription.php +++ b/src/Jobs/GenerateTranscription.php @@ -6,10 +6,12 @@ use Illuminate\Foundation\Queue\Queueable; use Laravel\Ai\Enums\Lab; use Laravel\Ai\PendingResponses\PendingTranscriptionGeneration; +use Laravel\Ai\Responses\TranscriptionResponse; class GenerateTranscription implements ShouldQueue { - use Concerns\InvokesQueuedResponseCallbacks, Queueable; + use Concerns\InvokesQueuedResponseCallbacks; + use Queueable; /** * Create a new job instance. @@ -24,7 +26,7 @@ public function __construct( */ public function handle(): void { - $this->withCallbacks(fn () => $this->pendingTranscription->generate( + $this->withCallbacks(fn (): TranscriptionResponse => $this->pendingTranscription->generate( $this->provider, $this->model, )); diff --git a/src/Jobs/InvokeAgent.php b/src/Jobs/InvokeAgent.php index 8b4a32a20..680ab5a84 100644 --- a/src/Jobs/InvokeAgent.php +++ b/src/Jobs/InvokeAgent.php @@ -6,10 +6,12 @@ use Illuminate\Foundation\Queue\Queueable; use Laravel\Ai\Contracts\Agent; use Laravel\Ai\Enums\Lab; +use Laravel\Ai\Responses\AgentResponse; class InvokeAgent implements ShouldQueue { - use Concerns\InvokesQueuedResponseCallbacks, Queueable; + use Concerns\InvokesQueuedResponseCallbacks; + use Queueable; /** * Create a new job instance. @@ -26,7 +28,7 @@ public function __construct( */ public function handle(): void { - $this->withCallbacks(fn () => $this->agent->prompt( + $this->withCallbacks(fn (): AgentResponse => $this->agent->prompt( $this->prompt, $this->attachments, $this->provider, diff --git a/src/Middleware/RememberConversation.php b/src/Middleware/RememberConversation.php index b316a310b..ddbf6fad3 100644 --- a/src/Middleware/RememberConversation.php +++ b/src/Middleware/RememberConversation.php @@ -10,6 +10,7 @@ use Laravel\Ai\Contracts\RemembersConversations; use Laravel\Ai\Messages\UserMessage; use Laravel\Ai\Prompts\AgentPrompt; +use Laravel\Ai\Responses\AgentResponse; use Throwable; class RememberConversation @@ -27,7 +28,7 @@ public function __construct( */ public function handle(AgentPrompt $prompt, Closure $next) { - return $next($prompt)->then(function ($response) use ($prompt) { + return $next($prompt)->then(function (AgentResponse $response) use ($prompt): void { /** @var Agent&RemembersConversations $agent */ $agent = $prompt->agent; diff --git a/src/Migrations/AiMigration.php b/src/Migrations/AiMigration.php index 65b8b90af..70bf9b98f 100644 --- a/src/Migrations/AiMigration.php +++ b/src/Migrations/AiMigration.php @@ -9,6 +9,7 @@ abstract class AiMigration extends Migration /** * Get the migration connection name. */ + #[\Override] public function getConnection(): ?string { return config('ai.conversations.connection', config('database.default')); diff --git a/src/Models/Conversation.php b/src/Models/Conversation.php index 2d94c8c8d..c8f77e406 100644 --- a/src/Models/Conversation.php +++ b/src/Models/Conversation.php @@ -2,18 +2,13 @@ namespace Laravel\Ai\Models; +use Illuminate\Database\Eloquent\Attributes\WithoutIncrementing; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +#[WithoutIncrementing] class Conversation extends Model { - /** - * Indicates if the model's ID is auto-incrementing. - * - * @var bool - */ - public $incrementing = false; - /** * The data type of the primary key ID. * @@ -41,6 +36,7 @@ public function messages(): HasMany /** * Get the table associated with the model. */ + #[\Override] public function getTable(): string { return config('ai.conversations.tables.conversations', 'agent_conversations'); @@ -49,6 +45,7 @@ public function getTable(): string /** * Get the database connection for the model. */ + #[\Override] public function getConnectionName(): ?string { return config('ai.conversations.connection'); diff --git a/src/Models/ConversationMessage.php b/src/Models/ConversationMessage.php index 52cbdfb7c..6a14f7640 100644 --- a/src/Models/ConversationMessage.php +++ b/src/Models/ConversationMessage.php @@ -2,18 +2,13 @@ namespace Laravel\Ai\Models; +use Illuminate\Database\Eloquent\Attributes\WithoutIncrementing; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +#[WithoutIncrementing] class ConversationMessage extends Model { - /** - * Indicates if the model's ID is auto-incrementing. - * - * @var bool - */ - public $incrementing = false; - /** * The data type of the primary key ID. * @@ -54,6 +49,7 @@ public function conversation(): BelongsTo /** * Get the table associated with the model. */ + #[\Override] public function getTable(): string { return config('ai.conversations.tables.messages', 'agent_conversation_messages'); @@ -62,6 +58,7 @@ public function getTable(): string /** * Get the database connection for the model. */ + #[\Override] public function getConnectionName(): ?string { return config('ai.conversations.connection'); diff --git a/src/ObjectSchema.php b/src/ObjectSchema.php index d389ad10a..6140c425e 100644 --- a/src/ObjectSchema.php +++ b/src/ObjectSchema.php @@ -26,6 +26,7 @@ public function __construct( * * @return array */ + #[\Override] public function toSchema(): array { return static::disableAdditionalProperties(parent::toSchema()); diff --git a/src/PendingResponses/PendingEmbeddingsGeneration.php b/src/PendingResponses/PendingEmbeddingsGeneration.php index ba5ac196c..7820989c4 100644 --- a/src/PendingResponses/PendingEmbeddingsGeneration.php +++ b/src/PendingResponses/PendingEmbeddingsGeneration.php @@ -21,7 +21,8 @@ class PendingEmbeddingsGeneration { - use Conditionable, ResolvesProviderOptions; + use Conditionable; + use ResolvesProviderOptions; protected ?int $dimensions = null; @@ -115,14 +116,14 @@ public function generate(Lab|array|string|null $provider = null, ?string $model $providerOptions = $this->resolveProviderOptions($provider); - if ($cached = $this->generateFromCache($provider, $model, $dimensions, $providerOptions)) { + if (($cached = $this->generateFromCache($provider, $model, $dimensions, $providerOptions)) instanceof EmbeddingsResponse) { return $cached; } try { return tap( $provider->embeddings($this->inputs, $dimensions, $model, $this->timeout, $providerOptions), - fn ($response) => $this->cacheEmbeddings($provider, $model, $dimensions, $providerOptions, $response) + fn (EmbeddingsResponse $response) => $this->cacheEmbeddings($provider, $model, $dimensions, $providerOptions, $response) ); } catch (FailoverableException $e) { $lastException = $e; @@ -150,7 +151,7 @@ protected function generateFromCache(Provider $provider, string $model, int $dim $response = $this->cacheStore()->get($this->cacheKey($provider, $model, $dimensions, $providerOptions)); if (! is_null($response)) { - $response = json_decode($response, true); + $response = json_decode((string) $response, true); return new EmbeddingsResponse($response['embeddings'], 0, new Meta( provider: $response['meta']['provider'], @@ -218,12 +219,12 @@ protected function normalizeForFingerprint(mixed $value): mixed } if (array_is_list($value)) { - return array_map(fn ($item) => $this->normalizeForFingerprint($item), $value); + return array_map($this->normalizeForFingerprint(...), $value); } ksort($value); - return array_map(fn ($item) => $this->normalizeForFingerprint($item), $value); + return array_map($this->normalizeForFingerprint(...), $value); } /** diff --git a/src/PendingResponses/PendingTranscriptionGeneration.php b/src/PendingResponses/PendingTranscriptionGeneration.php index 554f1045c..7081280b7 100644 --- a/src/PendingResponses/PendingTranscriptionGeneration.php +++ b/src/PendingResponses/PendingTranscriptionGeneration.php @@ -21,7 +21,8 @@ class PendingTranscriptionGeneration { - use Conditionable, ResolvesProviderOptions; + use Conditionable; + use ResolvesProviderOptions; protected ?string $language = null; diff --git a/src/Promptable.php b/src/Promptable.php index 8a41731ad..2a4a880ee 100644 --- a/src/Promptable.php +++ b/src/Promptable.php @@ -43,8 +43,8 @@ trait Promptable public static function make(...$arguments): static { return match (true) { - ! empty($arguments) && ! array_is_list($arguments) => Container::getInstance()->makeWith(static::class, $arguments), - ! empty($arguments) => new static(...$arguments), + $arguments !== [] && ! array_is_list($arguments) => Container::getInstance()->makeWith(static::class, $arguments), + $arguments !== [] => new static(...$arguments), default => Container::getInstance()->make(static::class), }; } @@ -60,7 +60,7 @@ public function prompt( ?int $timeout = null): AgentResponse { return $this->withModelFailover( - fn (TextProvider $provider, string $model) => $provider->prompt( + fn (TextProvider $provider, string $model): AgentResponse => $provider->prompt( new AgentPrompt($this, $prompt, $attachments, $provider, $model, $this->getTimeout($timeout)) ), $provider, @@ -107,7 +107,7 @@ function () use ($providers, $prompt, $attachments, $resolvedTimeout, $invocatio new AgentPrompt($this, $prompt, $attachments, $provider, $model, $resolvedTimeout, $invocationId) ); - $innerResponse->then(fn (StreamedAgentResponse $response) => $outer->adoptStateFrom($response)); + $innerResponse->then(fn (StreamedAgentResponse $response): StreamableAgentResponse => $outer->adoptStateFrom($response)); foreach ($innerResponse as $event) { $started = true; @@ -159,7 +159,7 @@ public function broadcast(string $prompt, Channel|array $channels, array $attach $without = WithoutBroadcasting::eventsFor($this); return $this->stream($prompt, $attachments, $provider, $model) - ->each(function (StreamEvent $event) use ($channels, $now, $without) { + ->each(function (StreamEvent $event) use ($channels, $now, $without): void { if (WithoutBroadcasting::excludes($without, $event)) { return; } @@ -262,7 +262,7 @@ protected function getProvidersAndModels(Lab|array|string|null $provider, ?strin } else { $attributes = (new ReflectionClass($this))->getAttributes(ProviderAttribute::class); - $provider = ! empty($attributes) ? $attributes[0]->newInstance()->value : null; + $provider = $attributes === [] ? null : $attributes[0]->newInstance()->value; } } @@ -272,7 +272,7 @@ protected function getProvidersAndModels(Lab|array|string|null $provider, ?strin } else { $attributes = (new ReflectionClass($this))->getAttributes(ModelAttribute::class); - $model = ! empty($attributes) ? $attributes[0]->newInstance()->value : null; + $model = $attributes === [] ? null : $attributes[0]->newInstance()->value; } } @@ -318,7 +318,7 @@ protected function getTimeout(?int $timeout): int $attributes = (new ReflectionClass($this))->getAttributes(TimeoutAttribute::class); - if (! empty($attributes)) { + if ($attributes !== []) { return $attributes[0]->newInstance()->value; } diff --git a/src/Providers/AnthropicProvider.php b/src/Providers/AnthropicProvider.php index f73862c22..b5008518d 100644 --- a/src/Providers/AnthropicProvider.php +++ b/src/Providers/AnthropicProvider.php @@ -27,9 +27,9 @@ public function webFetchToolOptions(WebFetch $fetch): array { return array_filter([ 'max_uses' => $fetch->maxSearches ?? 10, - 'allowed_domains' => ! empty($fetch->allowedDomains) - ? $fetch->allowedDomains - : null, + 'allowed_domains' => $fetch->allowedDomains === [] + ? null + : $fetch->allowedDomains, ]); } @@ -40,9 +40,9 @@ public function webSearchToolOptions(WebSearch $search): array { return array_filter([ 'max_uses' => $search->maxSearches, - 'allowed_domains' => ! empty($search->allowedDomains) - ? $search->allowedDomains - : null, + 'allowed_domains' => $search->allowedDomains === [] + ? null + : $search->allowedDomains, 'user_location' => $search->hasLocation() ? array_filter([ 'type' => 'approximate', diff --git a/src/Providers/AzureOpenAiProvider.php b/src/Providers/AzureOpenAiProvider.php index ce160fc3d..44982dd68 100644 --- a/src/Providers/AzureOpenAiProvider.php +++ b/src/Providers/AzureOpenAiProvider.php @@ -70,6 +70,7 @@ public function embeddingGateway(): EmbeddingGateway * * Azure OpenAI uses API key authentication via the `api-key` header. */ + #[\Override] public function providerCredentials(): array { return [ @@ -166,6 +167,7 @@ public function fileSearchToolOptions(FileSearch $search): array /** * Get the provider connection configuration other than the driver, key, and name. */ + #[\Override] public function additionalConfiguration(): array { return [ diff --git a/src/Providers/BedrockProvider.php b/src/Providers/BedrockProvider.php index 59c14f910..f38c7d8c4 100644 --- a/src/Providers/BedrockProvider.php +++ b/src/Providers/BedrockProvider.php @@ -30,6 +30,7 @@ public function __construct( /** * Get the credentials for the underlying AI provider. */ + #[\Override] public function providerCredentials(): array { return array_filter([ @@ -43,6 +44,7 @@ public function providerCredentials(): array /** * Get the provider connection configuration other than the driver, key, and name. */ + #[\Override] public function additionalConfiguration(): array { return [ diff --git a/src/Providers/Concerns/GeneratesAudio.php b/src/Providers/Concerns/GeneratesAudio.php index 2083df260..5cce56b69 100644 --- a/src/Providers/Concerns/GeneratesAudio.php +++ b/src/Providers/Concerns/GeneratesAudio.php @@ -37,7 +37,7 @@ public function audio( return tap($this->audioGateway()->generateAudio( $this, $model, $prompt->text, $prompt->voice, $prompt->instructions, $timeout, - ), function (AudioResponse $response) use ($invocationId, $model, $prompt) { + ), function (AudioResponse $response) use ($invocationId, $model, $prompt): void { $this->events->dispatch(new AudioGenerated( $invocationId, $this, $model, $prompt, $response, )); diff --git a/src/Providers/Concerns/GeneratesImages.php b/src/Providers/Concerns/GeneratesImages.php index cc24399b0..9b72a2fa3 100644 --- a/src/Providers/Concerns/GeneratesImages.php +++ b/src/Providers/Concerns/GeneratesImages.php @@ -42,7 +42,7 @@ public function image( return tap($this->imageGateway()->generateImage( $this, $model, $prompt->prompt, $prompt->attachments->all(), $prompt->size, $prompt->quality, $timeout, - ), function (ImageResponse $response) use ($invocationId, $prompt, $model) { + ), function (ImageResponse $response) use ($invocationId, $prompt, $model): void { $this->events->dispatch(new ImageGenerated( $invocationId, $this, $model, $prompt, $response, )); diff --git a/src/Providers/Concerns/GeneratesText.php b/src/Providers/Concerns/GeneratesText.php index 7b5e57aca..d4759d4f8 100644 --- a/src/Providers/Concerns/GeneratesText.php +++ b/src/Providers/Concerns/GeneratesText.php @@ -26,6 +26,7 @@ use Laravel\Ai\Responses\AgentResponse; use Laravel\Ai\Responses\StructuredAgentResponse; use Laravel\Ai\Responses\StructuredTextResponse; +use Laravel\Ai\Responses\TextResponse; use Laravel\Ai\Tools\AgentTool; use Laravel\Ai\Tools\McpServerTool; use Laravel\Ai\Tools\McpTool; @@ -48,7 +49,7 @@ public function prompt(AgentPrompt $prompt): AgentResponse $response = pipeline() ->send($prompt) ->through($this->gatherMiddlewareFor($prompt->agent)) - ->then(function (AgentPrompt $prompt) use ($invocationId, &$processedPrompt) { + ->then(function (AgentPrompt $prompt) use ($invocationId, &$processedPrompt): TextResponse { $processedPrompt = $prompt; $this->events->dispatch(new PromptingAgent($invocationId, $prompt)); @@ -150,14 +151,14 @@ protected function resolveTool(mixed $tool): mixed protected function listenForToolInvocations(string $invocationId, Agent $agent): void { $this->textGenerationLoop()->onToolInvocation( - invoking: function (Tool $tool, array $arguments) use ($invocationId, $agent) { + invoking: function (Tool $tool, array $arguments) use ($invocationId, $agent): void { $this->currentToolInvocationId = (string) Str::uuid7(); $this->events->dispatch(new InvokingTool( $invocationId, $this->currentToolInvocationId, $agent, $tool, $arguments )); }, - invoked: function (Tool $tool, array $arguments, mixed $result) use ($invocationId, $agent) { + invoked: function (Tool $tool, array $arguments, mixed $result) use ($invocationId, $agent): void { $this->events->dispatch(new ToolInvoked( $invocationId, $this->currentToolInvocationId, $agent, $tool, $arguments, $result )); diff --git a/src/Providers/Concerns/GeneratesTranscriptions.php b/src/Providers/Concerns/GeneratesTranscriptions.php index 986c4debd..3dd14aa28 100644 --- a/src/Providers/Concerns/GeneratesTranscriptions.php +++ b/src/Providers/Concerns/GeneratesTranscriptions.php @@ -41,7 +41,7 @@ public function transcribe( return tap($this->transcriptionGateway()->generateTranscription( $this, $model, $prompt->audio, $prompt->language, $prompt->diarize, $prompt->timeout ?? 30, $prompt->providerOptions - ), function (TranscriptionResponse $response) use ($invocationId, $model, $prompt) { + ), function (TranscriptionResponse $response) use ($invocationId, $model, $prompt): void { $this->events->dispatch(new TranscriptionGenerated( $invocationId, $this, $model, $prompt, $response )); diff --git a/src/Providers/Concerns/ManagesFiles.php b/src/Providers/Concerns/ManagesFiles.php index 832267558..0cd70aa97 100644 --- a/src/Providers/Concerns/ManagesFiles.php +++ b/src/Providers/Concerns/ManagesFiles.php @@ -38,7 +38,7 @@ public function putFile(StorableFile $file): StoredFileResponse return tap( $this->fileGateway()->putFile($this, $file), - function (StoredFileResponse $response) use ($invocationId, $file) { + function (StoredFileResponse $response) use ($invocationId, $file): void { $this->events->dispatch(new FileStored( $invocationId, $this, $file, $response, )); diff --git a/src/Providers/Concerns/ManagesStores.php b/src/Providers/Concerns/ManagesStores.php index 94e3d0290..ade0d745b 100644 --- a/src/Providers/Concerns/ManagesStores.php +++ b/src/Providers/Concerns/ManagesStores.php @@ -49,7 +49,7 @@ public function createStore( return tap( $this->storeGateway()->createStore($this, $name, $description, $fileIds, $expiresWhenIdleFor), - function (Store $store) use ($invocationId, $name, $description, $fileIds, $expiresWhenIdleFor) { + function (Store $store) use ($invocationId, $name, $description, $fileIds, $expiresWhenIdleFor): void { $this->events->dispatch(new StoreCreated( $invocationId, $this, $name, $description, $fileIds, $expiresWhenIdleFor, $store, )); @@ -70,7 +70,7 @@ public function addFileToStore(string $storeId, HasProviderId $file, array $meta return tap( $this->storeGateway()->addFile($this, $storeId, $file->id(), $metadata), - function (string $documentId) use ($invocationId, $storeId, $file) { + function (string $documentId) use ($invocationId, $storeId, $file): void { $this->events->dispatch(new FileAddedToStore( $invocationId, $this, $storeId, $file->id(), $documentId, )); @@ -97,7 +97,7 @@ public function removeFileFromStore(string $storeId, HasProviderId|string $docum return tap( $this->storeGateway()->removeFile($this, $storeId, $documentId), - function () use ($invocationId, $storeId, $documentId) { + function () use ($invocationId, $storeId, $documentId): void { $this->events->dispatch(new FileRemovedFromStore( $invocationId, $this, $storeId, $documentId, )); diff --git a/src/Providers/Concerns/StreamsText.php b/src/Providers/Concerns/StreamsText.php index 487ccf2ff..fd86a47da 100644 --- a/src/Providers/Concerns/StreamsText.php +++ b/src/Providers/Concerns/StreamsText.php @@ -31,7 +31,7 @@ public function stream(AgentPrompt $prompt): StreamableAgentResponse return pipeline() ->send($prompt) ->through($this->gatherMiddlewareFor($prompt->agent)) - ->then(function (AgentPrompt $prompt) use ($invocationId, &$processedPrompt) { + ->then(function (AgentPrompt $prompt) use ($invocationId, &$processedPrompt): StreamableAgentResponse { $processedPrompt = $prompt; $agent = $prompt->agent; @@ -68,7 +68,7 @@ function () use ($invocationId, $prompt, $agent) { }, $meta, ); - })->then(function (StreamedAgentResponse $response) use ($invocationId, $prompt, &$processedPrompt) { + })->then(function (StreamedAgentResponse $response) use ($invocationId, $prompt, &$processedPrompt): void { $this->events->dispatch( new AgentStreamed($invocationId, $processedPrompt ?? $prompt, $response) ); diff --git a/src/Providers/GeminiProvider.php b/src/Providers/GeminiProvider.php index b3e3e737a..4643aa016 100644 --- a/src/Providers/GeminiProvider.php +++ b/src/Providers/GeminiProvider.php @@ -46,9 +46,9 @@ public function fileSearchToolOptions(FileSearch $search): array { return array_filter([ 'fileSearchStoreNames' => $search->ids(), - 'metadataFilter' => ! empty($search->filters) - ? $this->formatMetadataFilter($search->filters) - : null, + 'metadataFilter' => $search->filters === [] + ? null + : $this->formatMetadataFilter($search->filters), ]); } @@ -59,14 +59,14 @@ public function fileSearchToolOptions(FileSearch $search): array */ protected function formatMetadataFilter(array $filters): string { - return (new Collection($filters))->map(fn ($filter) => match ($filter['type']) { + return (new Collection($filters))->map(fn ($filter): string => match ($filter['type']) { 'eq' => is_numeric($filter['value']) ? "{$filter['key']}={$filter['value']}" : "{$filter['key']}=\"{$filter['value']}\"", 'ne' => is_numeric($filter['value']) ? "{$filter['key']}!={$filter['value']}" : "{$filter['key']}!=\"{$filter['value']}\"", - 'in' => '('.(new Collection($filter['value']))->map(fn ($v) => is_numeric($v) ? "{$filter['key']}={$v}" : "{$filter['key']}=\"{$v}\"" + 'in' => '('.(new Collection($filter['value']))->map(fn ($v): string => is_numeric($v) ? "{$filter['key']}={$v}" : "{$filter['key']}=\"{$v}\"" )->implode(' OR ').')', })->implode(' AND '); } diff --git a/src/Providers/OllamaProvider.php b/src/Providers/OllamaProvider.php index 270e5b24e..35c59a600 100644 --- a/src/Providers/OllamaProvider.php +++ b/src/Providers/OllamaProvider.php @@ -27,6 +27,7 @@ public function __construct(protected array $config, protected Dispatcher $event /** * Get the credentials for the Ollama provider (API key is optional). */ + #[\Override] public function providerCredentials(): array { return [ diff --git a/src/Providers/OpenAiCompatibleProvider.php b/src/Providers/OpenAiCompatibleProvider.php index adf3c968e..5d112f822 100644 --- a/src/Providers/OpenAiCompatibleProvider.php +++ b/src/Providers/OpenAiCompatibleProvider.php @@ -22,6 +22,7 @@ public function __construct(protected array $config, protected Dispatcher $event /** * Get the credentials for the underlying AI provider. */ + #[\Override] public function providerCredentials(): array { return ['key' => $this->config['key'] ?? null]; diff --git a/src/Providers/OpenAiProvider.php b/src/Providers/OpenAiProvider.php index 516171877..4ffc08cc2 100644 --- a/src/Providers/OpenAiProvider.php +++ b/src/Providers/OpenAiProvider.php @@ -47,7 +47,7 @@ public function fileSearchToolOptions(FileSearch $search): array 'vector_store_ids' => $search->ids(), 'filters' => filled($search->filters) ? [ 'type' => 'and', - 'filters' => (new Collection($search->filters))->map(fn ($filter) => [ + 'filters' => (new Collection($search->filters))->map(fn ($filter): array => [ 'type' => $filter['type'], 'key' => $filter['key'], 'value' => $filter['value'], diff --git a/src/Providers/Provider.php b/src/Providers/Provider.php index 838fbad3b..ac3463279 100644 --- a/src/Providers/Provider.php +++ b/src/Providers/Provider.php @@ -8,7 +8,7 @@ use Laravel\Ai\Contracts\Providers\Provider as ProviderContract; use Laravel\Ai\Enums\Lab; -abstract class Provider implements ProviderContract +abstract class Provider implements \Stringable, ProviderContract { public function __construct( protected Gateway $gateway, @@ -62,11 +62,9 @@ public static function formatProviderAndModelList(Lab|array|string $providers, ? return [$providers => $model]; } - return (new Collection($providers))->mapWithKeys(function ($value, $key) { - return is_numeric($key) - ? [($value instanceof Lab ? $value->value : $value) => null] - : [($key instanceof Lab ? $key->value : $key) => $value]; - })->all(); + return (new Collection($providers))->mapWithKeys(fn ($value, $key): array => is_numeric($key) + ? [($value instanceof Lab ? $value->value : $value) => null] + : [($key instanceof Lab ? $key->value : $key) => $value])->all(); } /** diff --git a/src/Providers/Tools/FileSearch.php b/src/Providers/Tools/FileSearch.php index 41d655063..04be86774 100644 --- a/src/Providers/Tools/FileSearch.php +++ b/src/Providers/Tools/FileSearch.php @@ -33,11 +33,9 @@ public function __construct( public function ids(): array { return (new Collection($this->stores)) - ->map(function ($store) { - return $store instanceof Store - ? $store->id - : $store; - })->all(); + ->map(fn ($store) => $store instanceof Store + ? $store->id + : $store)->all(); } /** @@ -53,7 +51,7 @@ protected function resolveFilters(Closure|array|null $where): array } if (is_array($where)) { - return (new Collection($where))->map(fn ($value, $key) => [ + return (new Collection($where))->map(fn ($value, $key): array => [ 'type' => 'eq', 'key' => $key, 'value' => $value, diff --git a/src/Providers/Tools/WebSearch.php b/src/Providers/Tools/WebSearch.php index 3bc728b8c..a9eb1b1e4 100644 --- a/src/Providers/Tools/WebSearch.php +++ b/src/Providers/Tools/WebSearch.php @@ -52,8 +52,8 @@ public function location(?string $city = null, ?string $region = null, ?string $ */ public function hasLocation(): bool { - return isset($this->city) || - isset($this->region) || - isset($this->country); + return $this->city !== null || + $this->region !== null || + $this->country !== null; } } diff --git a/src/Responses/AudioResponse.php b/src/Responses/AudioResponse.php index 8b7031134..0893f4c93 100644 --- a/src/Responses/AudioResponse.php +++ b/src/Responses/AudioResponse.php @@ -6,7 +6,7 @@ use Laravel\Ai\Concerns\Storable; use Laravel\Ai\Responses\Data\Meta; -class AudioResponse +class AudioResponse implements \Stringable { use Storable; @@ -28,7 +28,7 @@ public function __construct( */ protected function randomStorageName(): string { - return once(fn () => Str::random(40).match ($this->mime) { + return once(fn (): string => Str::random(40).match ($this->mime) { 'audio/wav', 'audio/x-wav' => '.wav', default => '.mp3', }); diff --git a/src/Responses/Data/GeneratedImage.php b/src/Responses/Data/GeneratedImage.php index 92a180c04..e60a0dc1f 100644 --- a/src/Responses/Data/GeneratedImage.php +++ b/src/Responses/Data/GeneratedImage.php @@ -7,7 +7,7 @@ use JsonSerializable; use Laravel\Ai\Concerns\Storable; -class GeneratedImage implements Arrayable, JsonSerializable +class GeneratedImage implements \Stringable, Arrayable, JsonSerializable { use Storable; @@ -36,7 +36,7 @@ public function mime(): string */ protected function randomStorageName(): string { - return once(fn () => Str::random(40).match ($this->mime()) { + return once(fn (): string => Str::random(40).match ($this->mime()) { 'image/jpeg' => '.jpg', 'image/png' => '.png', 'image/webp' => '.webp', diff --git a/src/Responses/Data/StructuredStep.php b/src/Responses/Data/StructuredStep.php index c4cbba186..42c633b17 100644 --- a/src/Responses/Data/StructuredStep.php +++ b/src/Responses/Data/StructuredStep.php @@ -24,6 +24,7 @@ public function __construct( /** * Get the instance as an array. */ + #[\Override] public function toArray(): array { return [...parent::toArray(), 'structured' => $this->structured]; @@ -32,6 +33,7 @@ public function toArray(): array /** * Get the JSON serializable representation of the instance. */ + #[\Override] public function jsonSerialize(): mixed { return $this->toArray(); diff --git a/src/Responses/ImageResponse.php b/src/Responses/ImageResponse.php index b02c9598f..fafcc86d9 100644 --- a/src/Responses/ImageResponse.php +++ b/src/Responses/ImageResponse.php @@ -10,7 +10,7 @@ use Laravel\Ai\Responses\Data\Usage; use RuntimeException; -class ImageResponse implements Countable, Htmlable +class ImageResponse implements \Stringable, Countable, Htmlable { /** * @param Collection $images diff --git a/src/Responses/StreamableAgentResponse.php b/src/Responses/StreamableAgentResponse.php index 29b8ceb6e..e42f12b31 100644 --- a/src/Responses/StreamableAgentResponse.php +++ b/src/Responses/StreamableAgentResponse.php @@ -19,9 +19,9 @@ class StreamableAgentResponse implements IteratorAggregate, Responsable { use Concerns\CanStreamUsingVercelProtocol; - public ?string $text; + public ?string $text = null; - public ?Usage $usage; + public ?Usage $usage = null; /** @var Collection */ public Collection $events; @@ -64,7 +64,7 @@ public function each(callable $callback): self public function then(callable $callback): self { // If the response has already been iterated / streamed, invoke now... - if ($this->streamedResponse) { + if ($this->streamedResponse instanceof StreamedAgentResponse) { $callback($this->streamedResponse); $this->syncConversationFromStreamedResponse(); @@ -93,7 +93,7 @@ public function withinConversation(?string $conversationId, ?object $conversatio */ public function adoptStateFrom(StreamedAgentResponse $response): self { - if ($this->meta !== null) { + if ($this->meta instanceof Meta) { $this->meta->provider = $response->meta->provider; $this->meta->model = $response->meta->model; $this->meta->citations = $response->meta->citations; @@ -131,7 +131,7 @@ public function toResponse($request): Response return response()->stream(function () { foreach ($this as $event) { - yield 'data: '.((string) $event)."\n\n"; + yield 'data: '.($event)."\n\n"; } yield "data: [DONE]\n\n"; diff --git a/src/Responses/StructuredAgentResponse.php b/src/Responses/StructuredAgentResponse.php index 77f01fecf..76735ab30 100644 --- a/src/Responses/StructuredAgentResponse.php +++ b/src/Responses/StructuredAgentResponse.php @@ -52,8 +52,9 @@ public function jsonSerialize(): mixed /** * Get the string representation of the object. */ + #[\Override] public function __toString(): string { - return json_encode($this->structured); + return (string) json_encode($this->structured); } } diff --git a/src/Responses/StructuredTextResponse.php b/src/Responses/StructuredTextResponse.php index fb5c31f86..ea82f6954 100644 --- a/src/Responses/StructuredTextResponse.php +++ b/src/Responses/StructuredTextResponse.php @@ -23,8 +23,9 @@ public function __construct(array $structured, string $text, public Usage $usage /** * Get the string representation of the object. */ + #[\Override] public function __toString(): string { - return json_encode($this->structured); + return (string) json_encode($this->structured); } } diff --git a/src/Responses/TextResponse.php b/src/Responses/TextResponse.php index fe91109ac..0da9fa6d3 100644 --- a/src/Responses/TextResponse.php +++ b/src/Responses/TextResponse.php @@ -12,7 +12,7 @@ use Laravel\Ai\Responses\Data\ToolResult; use Laravel\Ai\Responses\Data\Usage; -class TextResponse +class TextResponse implements \Stringable { /** @var Collection */ public Collection $messages; @@ -70,7 +70,7 @@ public function withToolCallsAndResults(Collection $toolCalls, Collection $toolR { // Filter Anthropic tool use for "JSON mode"... $this->toolCalls = $toolCalls->reject( - fn ($toolCall) => $toolCall->name === 'output_structured_data' + fn ($toolCall): bool => $toolCall->name === 'output_structured_data' )->values(); $this->toolResults = $toolResults->values(); diff --git a/src/Responses/TranscriptionResponse.php b/src/Responses/TranscriptionResponse.php index 47fede8e2..403f723b8 100644 --- a/src/Responses/TranscriptionResponse.php +++ b/src/Responses/TranscriptionResponse.php @@ -7,7 +7,7 @@ use Laravel\Ai\Responses\Data\TranscriptionSegment; use Laravel\Ai\Responses\Data\Usage; -class TranscriptionResponse +class TranscriptionResponse implements \Stringable { public string $text; diff --git a/src/Schema/SchemaNormalizer.php b/src/Schema/SchemaNormalizer.php index 9d1ef9508..47f134c6a 100644 --- a/src/Schema/SchemaNormalizer.php +++ b/src/Schema/SchemaNormalizer.php @@ -2,6 +2,8 @@ namespace Laravel\Ai\Schema; +use Illuminate\JsonSchema\Types\AnyOfType; + class SchemaNormalizer { /** @@ -235,10 +237,12 @@ private function preserveAnyOf(array $schema, array $root, array $seen): array $resolved = false; foreach ($schema['anyOf'] as $branch) { - if (! is_array($branch) || $branch === []) { + if (! is_array($branch)) { + continue; + } + if ($branch === []) { continue; } - [$branch, $branchSeen] = $this->inlineRefs($branch, $root, $seen); if ($branch === []) { @@ -330,7 +334,7 @@ private function mergeUnion(array $schema, array $branches, array $root, array $ */ private function mergeObjectVariants(array $schema, array $branches): ?array { - $objects = array_values(array_filter($branches, fn ($branch) => $this->isObjectBranch($branch))); + $objects = array_values(array_filter($branches, $this->isObjectBranch(...))); if (count($objects) < 2) { return null; @@ -422,13 +426,13 @@ private function reduceObjectGroup(array $variants, array $outer = []): array */ private function mergeVariantValues(array $list): array { - $list = array_values(array_filter($list, 'is_array')); + $list = array_values(array_filter($list, is_array(...))); if (count($list) <= 1) { return $list[0] ?? []; } - $objects = array_values(array_filter($list, fn ($value) => $this->isObjectBranch($value))); + $objects = array_values(array_filter($list, $this->isObjectBranch(...))); if (count($objects) >= 2) { return $this->reduceObjectGroup($objects); @@ -462,7 +466,7 @@ private function unionScalarValues(array $list): array } } - $types = array_values(array_unique(array_filter($types, fn ($type) => $type !== null))); + $types = array_values(array_unique(array_filter($types, fn ($type): bool => $type !== null))); if (! isset($merged['items']) && $types !== []) { $merged['type'] = count($types) === 1 ? $types[0] : $types; @@ -510,7 +514,7 @@ private function firstObjectBranch(array $branches): ?array private function isObjectBranch(array $branch): bool { $type = $branch['type'] ?? null; - $nonNull = array_values(array_filter(is_array($type) ? $type : [$type], fn ($value) => $value !== 'null')); + $nonNull = array_values(array_filter(is_array($type) ? $type : [$type], fn ($value): bool => $value !== 'null')); return $nonNull === ['object']; } @@ -520,7 +524,7 @@ private function isObjectBranch(array $branch): bool */ private function supportsAnyOf(): bool { - return class_exists('Illuminate\\JsonSchema\\Types\\AnyOfType'); + return class_exists(AnyOfType::class); } /** @@ -580,8 +584,8 @@ private function collapseMultiType(array $schema): array return $schema; } - $valid = array_values(array_filter($type, fn ($value) => in_array($value, self::TYPES, true))); - $nonNull = array_values(array_filter($valid, fn ($value) => $value !== 'null')); + $valid = array_values(array_filter($type, fn ($value): bool => in_array($value, self::TYPES, true))); + $nonNull = array_values(array_filter($valid, fn ($value): bool => $value !== 'null')); if ($nonNull === []) { unset($schema['type']); @@ -658,7 +662,7 @@ private function normalizeChildren(array $schema, array $root, array $seen): arr if (is_array($schema['required'] ?? null)) { $schema['required'] = array_values(array_filter( $schema['required'], - fn ($name) => is_string($name) && array_key_exists($name, $properties), + fn ($name): bool => is_string($name) && array_key_exists($name, $properties), )); } } @@ -689,9 +693,9 @@ private function ensureType(array $schema): array } $type = $schema['type'] ?? null; - $nonNull = array_filter(is_array($type) ? $type : [$type], fn ($t) => $t !== 'null'); + $nonNull = array_filter(is_array($type) ? $type : [$type], fn ($t): bool => $t !== 'null'); - if ($nonNull !== [] && array_filter($nonNull, fn ($t) => ! in_array($t, self::TYPES, true)) === []) { + if ($nonNull !== [] && array_filter($nonNull, fn ($t): bool => ! in_array($t, self::TYPES, true)) === []) { return $schema; } diff --git a/src/Storage/DatabaseConversationStore.php b/src/Storage/DatabaseConversationStore.php index a52cc4d15..547c44516 100644 --- a/src/Storage/DatabaseConversationStore.php +++ b/src/Storage/DatabaseConversationStore.php @@ -141,9 +141,9 @@ public function getLatestConversationMessages(string $conversationId, int $limit ->get() ->reverse() ->values() - ->flatMap(function ($record) { - $toolCalls = collect(json_decode($record->tool_calls, true))->values(); - $toolResults = collect(json_decode($record->tool_results, true))->values(); + ->flatMap(function ($record): array { + $toolCalls = collect(json_decode((string) $record->tool_calls, true))->values(); + $toolResults = collect(json_decode((string) $record->tool_results, true))->values(); if ($record->role === 'user') { $attachments = $this->rehydrateAttachments($record->attachments); @@ -196,7 +196,7 @@ protected function rehydrateAttachments(string $attachments): Collection } return collect($decoded) - ->map(function (mixed $attachment) { + ->map(function (mixed $attachment): ?File { if (! is_array($attachment)) { throw new InvalidArgumentException('Stored conversation attachment entries must be objects.'); } diff --git a/src/Store.php b/src/Store.php index 1d5e1f0db..286844c6a 100644 --- a/src/Store.php +++ b/src/Store.php @@ -142,12 +142,12 @@ public function assertNotRemoved(Closure|string $documentId): self protected function fileAssertionCallback(Closure|string $fileId): Closure { if ($fileId instanceof Closure) { - return fn ($s, $f) => $s === $this->id && $fileId($f); + return fn ($s, $f): bool => $s === $this->id && $fileId($f); } $expectedFileId = str_starts_with($fileId, 'fake_file_') ? $fileId : Files::fakeId($fileId); - return fn ($s, $f) => $s === $this->id && $this->fileIdMatches($f, $expectedFileId); + return fn ($s, $f): bool => $s === $this->id && $this->fileIdMatches($f, $expectedFileId); } /** diff --git a/src/Streaming/Events/StreamEnd.php b/src/Streaming/Events/StreamEnd.php index fa79f1eb2..8c71f4829 100644 --- a/src/Streaming/Events/StreamEnd.php +++ b/src/Streaming/Events/StreamEnd.php @@ -25,7 +25,7 @@ public static function combineUsage(Collection|array $events): Usage return $events->whereInstanceOf(StreamEnd::class) ->values() - ->map(fn (StreamEnd $event) => $event->usage) + ->map(fn (StreamEnd $event): Usage => $event->usage) ->reduce(fn ($a, $b) => $a->add($b), new Usage); } diff --git a/src/Streaming/Events/StreamEvent.php b/src/Streaming/Events/StreamEvent.php index b19411afc..c59230c7b 100644 --- a/src/Streaming/Events/StreamEvent.php +++ b/src/Streaming/Events/StreamEvent.php @@ -6,7 +6,7 @@ use Illuminate\Broadcasting\Channel; use Illuminate\Support\Facades\Broadcast; -abstract class StreamEvent +abstract class StreamEvent implements \Stringable { public ?string $invocationId = null; @@ -27,8 +27,8 @@ public function broadcast(Channel|array $channels, bool $now = false): void ->as($this->type()) ->with($this->toArray()) ->{$now ? 'sendNow' : 'send'}(); - } catch (BroadcastException $e) { - report($e); + } catch (BroadcastException $broadcastException) { + report($broadcastException); } } @@ -71,6 +71,6 @@ public function toVercelProtocolArray(): ?array */ public function __toString(): string { - return json_encode($this->toArray()); + return (string) json_encode($this->toArray()); } } diff --git a/src/StructuredAnonymousAgent.php b/src/StructuredAnonymousAgent.php index 4d7a1e20f..2e0968f0f 100644 --- a/src/StructuredAnonymousAgent.php +++ b/src/StructuredAnonymousAgent.php @@ -9,6 +9,9 @@ class StructuredAnonymousAgent extends AnonymousAgent implements HasStructuredOutput { + /** + * @var SerializableClosure|null + */ public $schema; public function __construct( @@ -17,7 +20,7 @@ public function __construct( public iterable $tools, ?Closure $schema = null, ) { - $this->schema = $schema ? new SerializableClosure($schema) : null; + $this->schema = $schema instanceof Closure ? new SerializableClosure($schema) : null; } public function schema(JsonSchema $schema): array diff --git a/src/Tools/AgentTool.php b/src/Tools/AgentTool.php index b96844206..6222e62d2 100644 --- a/src/Tools/AgentTool.php +++ b/src/Tools/AgentTool.php @@ -46,8 +46,8 @@ public function handle(Request $request): string { try { return $this->agent->prompt((string) $request['task'])->text; - } catch (Throwable $e) { - return 'Agent failed: '.$e->getMessage(); + } catch (Throwable $throwable) { + return 'Agent failed: '.$throwable->getMessage(); } } diff --git a/src/Tools/Filesystem/CopyFile.php b/src/Tools/Filesystem/CopyFile.php index 883aa801d..9f3e7262a 100644 --- a/src/Tools/Filesystem/CopyFile.php +++ b/src/Tools/Filesystem/CopyFile.php @@ -28,8 +28,8 @@ public function handle(Request $request): string try { $copied = $this->disk()->copy($from, $to); - } catch (Throwable $e) { - return "Unable to copy [{$from}] to [{$to}]: {$e->getMessage()}"; + } catch (Throwable $throwable) { + return "Unable to copy [{$from}] to [{$to}]: {$throwable->getMessage()}"; } return $copied diff --git a/src/Tools/Filesystem/GetFileUrl.php b/src/Tools/Filesystem/GetFileUrl.php index 9e8f2847f..86f67d507 100644 --- a/src/Tools/Filesystem/GetFileUrl.php +++ b/src/Tools/Filesystem/GetFileUrl.php @@ -39,8 +39,8 @@ public function handle(Request $request): string return $minutes > 0 ? $disk->temporaryUrl($path, now()->addMinutes($minutes)) : $disk->url($path); - } catch (Throwable $e) { - return "Unable to generate a URL for [{$path}]: {$e->getMessage()}"; + } catch (Throwable $throwable) { + return "Unable to generate a URL for [{$path}]: {$throwable->getMessage()}"; } } @@ -54,7 +54,7 @@ public function schema(JsonSchema $schema): array ->description('The file path, relative to the disk root.') ->required(), 'expires_in_minutes' => $schema->integer() - ->description('Number of minutes a temporary signed URL stays valid, or null for the disk\'s standard (non-expiring) URL.') + ->description("Number of minutes a temporary signed URL stays valid, or null for the disk's standard (non-expiring) URL.") ->nullable() ->required(), ]; diff --git a/src/Tools/McpServerTool.php b/src/Tools/McpServerTool.php index 6f6473e37..39ea59071 100644 --- a/src/Tools/McpServerTool.php +++ b/src/Tools/McpServerTool.php @@ -8,6 +8,8 @@ use Illuminate\JsonSchema\Types\Type; use Laravel\Ai\Contracts\Tool; use Laravel\Ai\Tools\Concerns\NormalizesMcpResult; +use Laravel\Mcp\Response; +use Laravel\Mcp\ResponseFactory; class McpServerTool implements Tool { @@ -16,22 +18,22 @@ class McpServerTool implements Tool /** * The MCP server tool class name. */ - protected const MCP_SERVER_TOOL = 'Laravel\\Mcp\\Server\\Tool'; + protected const MCP_SERVER_TOOL = \Laravel\Mcp\Server\Tool::class; /** * The MCP request class name. */ - protected const MCP_REQUEST = 'Laravel\\Mcp\\Request'; + protected const MCP_REQUEST = \Laravel\Mcp\Request::class; /** * The MCP response class name. */ - protected const MCP_RESPONSE = 'Laravel\\Mcp\\Response'; + protected const MCP_RESPONSE = Response::class; /** * The MCP response factory class name. */ - protected const MCP_RESPONSE_FACTORY = 'Laravel\\Mcp\\ResponseFactory'; + protected const MCP_RESPONSE_FACTORY = ResponseFactory::class; /** * Create a new MCP server tool wrapper instance. @@ -43,7 +45,7 @@ public function __construct(protected object $tool) {} */ public static function supports(mixed $tool): bool { - return is_object($tool) && is_a($tool, self::MCP_SERVER_TOOL); + return $tool instanceof \Laravel\Mcp\Server\Tool; } /** diff --git a/src/Tools/McpTool.php b/src/Tools/McpTool.php index b1e6f907b..b4ecedd09 100644 --- a/src/Tools/McpTool.php +++ b/src/Tools/McpTool.php @@ -18,7 +18,7 @@ class McpTool implements Tool /** * The MCP client tool primitive class name. */ - protected const MCP_CLIENT_TOOL = 'Laravel\\Mcp\\Client\\Primitives\\Tool'; + protected const MCP_CLIENT_TOOL = \Laravel\Mcp\Client\Primitives\Tool::class; /** * The prefix applied to MCP client tool names. @@ -35,7 +35,7 @@ public function __construct(protected object $tool) {} */ public static function supports(mixed $tool): bool { - return is_object($tool) && is_a($tool, self::MCP_CLIENT_TOOL); + return $tool instanceof \Laravel\Mcp\Client\Primitives\Tool; } /** diff --git a/src/Tools/Request.php b/src/Tools/Request.php index 0bf73b33c..0b46ab1dc 100644 --- a/src/Tools/Request.php +++ b/src/Tools/Request.php @@ -10,7 +10,9 @@ class Request implements Arrayable, ArrayAccess { - use Conditionable, InteractsWithData, Macroable; + use Conditionable; + use InteractsWithData; + use Macroable; public function __construct(protected array $arguments = []) {} diff --git a/src/Tools/SimilaritySearch.php b/src/Tools/SimilaritySearch.php index bca407d59..2f85f2109 100644 --- a/src/Tools/SimilaritySearch.php +++ b/src/Tools/SimilaritySearch.php @@ -11,7 +11,7 @@ class SimilaritySearch implements Tool { - protected ?string $description; + protected ?string $description = null; protected bool $rerank = false; @@ -46,11 +46,11 @@ public static function usingModel( return new self(function (string $queryString) use ($model, $column, $minSimilarity, $limit, $query) { $pendingQuery = $model::query()->whereVectorSimilarTo($column, $queryString, $minSimilarity); - if ($query) { + if ($query instanceof Closure) { $pendingQuery = $query($pendingQuery); } - if ($limit) { + if ($limit !== 0) { $pendingQuery->limit($limit); } diff --git a/tests/Expectations.php b/tests/Expectations.php index 30b7ae412..c65720c86 100644 --- a/tests/Expectations.php +++ b/tests/Expectations.php @@ -1,6 +1,6 @@ extend('toBeValidAgentResponse', function (?string $provider = null) { +expect()->extend('toBeValidAgentResponse', function (?string $provider = null): object { $this->text->not->toBeEmpty() ->steps->not->toBeEmpty(); @@ -11,7 +11,7 @@ return $this; }); -expect()->extend('toContainStreamEventTypes', function (array $eventClasses) { +expect()->extend('toContainStreamEventTypes', function (array $eventClasses): object { $types = array_map(fn ($e) => $e::class, $this->value); foreach ($eventClasses as $class) { @@ -21,7 +21,7 @@ return $this; }); -expect()->extend('toHaveUsage', function (int $promptTokens, int $completionTokens) { +expect()->extend('toHaveUsage', function (int $promptTokens, int $completionTokens): object { $this->usage->promptTokens->toBe($promptTokens) ->usage->completionTokens->toBe($completionTokens); diff --git a/tests/Feature/AgentAttributeTest.php b/tests/Feature/AgentAttributeTest.php index 797f15c21..de5e7af6a 100644 --- a/tests/Feature/AgentAttributeTest.php +++ b/tests/Feature/AgentAttributeTest.php @@ -10,7 +10,7 @@ use Tests\Fixtures\Agents\MethodOverridesAttributeAgent; use Tests\Fixtures\Agents\NullableMethodOptionsAgent; -test('text generation options can be created from agent attributes', function () { +test('text generation options can be created from agent attributes', function (): void { $options = TextGenerationOptions::forAgent(new AttributeAgent); expect($options->maxSteps)->toBe(10) @@ -19,7 +19,7 @@ ->and($options->topP)->toBe(0.8); }); -test('text generation options are null when agent has no attributes', function () { +test('text generation options are null when agent has no attributes', function (): void { $options = TextGenerationOptions::forAgent(new AssistantAgent); expect($options->maxSteps)->toBeNull() @@ -28,7 +28,7 @@ ->and($options->topP)->toBeNull(); }); -test('text generation options can be resolved from agent methods', function () { +test('text generation options can be resolved from agent methods', function (): void { $options = TextGenerationOptions::forAgent(new MethodOptionsAgent); expect($options->maxSteps)->toBe(3) @@ -36,7 +36,7 @@ ->and($options->temperature)->toBe(0.5); }); -test('agent methods take priority over attributes', function () { +test('agent methods take priority over attributes', function (): void { $options = TextGenerationOptions::forAgent(new MethodOverridesAttributeAgent); expect($options->maxSteps)->toBe(1) @@ -44,7 +44,7 @@ ->and($options->temperature)->toBe(0.2); }); -test('null return from agent method falls back to attributes', function () { +test('null return from agent method falls back to attributes', function (): void { $options = TextGenerationOptions::forAgent(new NullableMethodOptionsAgent); expect($options->maxSteps)->toBe(10) @@ -52,7 +52,7 @@ ->and($options->temperature)->toBe(0.7); }); -test('non-public or parameterized option methods are ignored', function () { +test('non-public or parameterized option methods are ignored', function (): void { $options = TextGenerationOptions::forAgent(new IncompatibleMethodsAgent); expect($options->maxSteps)->toBe(8) @@ -60,12 +60,10 @@ ->and($options->temperature)->toBe(0.9); }); -test('provider attribute is used when prompting', function () { +test('provider attribute is used when prompting', function (): void { AttributeAgent::fake(); (new AttributeAgent)->prompt('Hello'); - AttributeAgent::assertPrompted(function (AgentPrompt $prompt) { - return $prompt->provider->name() === Lab::Anthropic->value; - }); + AttributeAgent::assertPrompted(fn (AgentPrompt $prompt): bool => $prompt->provider->name() === Lab::Anthropic->value); }); diff --git a/tests/Feature/AgentFakeTest.php b/tests/Feature/AgentFakeTest.php index 6c2e883a6..3503b2484 100644 --- a/tests/Feature/AgentFakeTest.php +++ b/tests/Feature/AgentFakeTest.php @@ -20,11 +20,11 @@ use Tests\Fixtures\Agents\MultiStepToolAgent; use Tests\Fixtures\Agents\StructuredAgent; -describe('prompt responses', function () { - test('agents can be faked', function () { +describe('prompt responses', function (): void { + test('agents can be faked', function (): void { AssistantAgent::fake([ 'First response', - fn (string $prompt) => 'Second response ('.$prompt.')', + fn (string $prompt): string => 'Second response ('.$prompt.')', new TextResponse('Third response', new Usage, new Meta), ]); @@ -41,18 +41,16 @@ AssistantAgent::assertPrompted('First prompt'); AssistantAgent::assertNotPrompted('Missing prompt'); - AssistantAgent::assertPrompted(function (AgentPrompt $prompt) { - return $prompt->prompt === 'First prompt'; - }); + AssistantAgent::assertPrompted(fn (AgentPrompt $prompt): bool => $prompt->prompt === 'First prompt'); }); - test('can assert agent was never prompted', function () { + test('can assert agent was never prompted', function (): void { AssistantAgent::fake(); AssistantAgent::assertNeverPrompted(); }); - test('agents can be faked with no predefined responses', function () { + test('agents can be faked with no predefined responses', function (): void { AssistantAgent::fake(); $response = (new AssistantAgent)->prompt('First prompt'); @@ -62,10 +60,8 @@ expect($response->text)->toEqual('Fake response for prompt: Second prompt'); }); - test('agents can be faked with a single closure that is invoked for every prompt', function () { - AssistantAgent::fake(function (string $prompt) { - return 'Fake response for prompt: '.$prompt; - }); + test('agents can be faked with a single closure that is invoked for every prompt', function (): void { + AssistantAgent::fake(fn (string $prompt): string => 'Fake response for prompt: '.$prompt); $response = (new AssistantAgent)->prompt('First prompt'); expect($response->text)->toEqual('Fake response for prompt: First prompt'); @@ -74,16 +70,16 @@ expect($response->text)->toEqual('Fake response for prompt: Second prompt'); }); - test('agents can prevent stray prompts', function () { + test('agents can prevent stray prompts', function (): void { AssistantAgent::fake()->preventStrayPrompts(); $response = (new AssistantAgent)->prompt('First prompt'); })->throws(RuntimeException::class); - test('agents with structured output can be faked', function () { + test('agents with structured output can be faked', function (): void { StructuredAgent::fake([ ['symbol' => 'Au'], - fn (string $prompt) => ['symbol' => 'Ag ('.$prompt.')'], + fn (string $prompt): array => ['symbol' => 'Ag ('.$prompt.')'], new StructuredTextResponse( ['symbol' => 'Pb'], json_encode(['symbol' => 'Pb']), @@ -102,7 +98,7 @@ expect($response['symbol'])->toEqual('Pb'); }); - test('agents with structured output can be faked with no predefined responses', function () { + test('agents with structured output can be faked with no predefined responses', function (): void { StructuredAgent::fake(); $response = (new StructuredAgent)->prompt('Gold prompt'); @@ -110,15 +106,15 @@ expect($response['symbol'])->toBeString(); }); - test('fake closures can throw exceptions', function () { - AssistantAgent::fake(function () { + test('fake closures can throw exceptions', function (): void { + AssistantAgent::fake(function (): void { throw new Exception('Something went wrong'); }); $response = (new AssistantAgent)->prompt('Test prompt'); })->throws(Exception::class); - test('structured agents with empty schemas fall back to a text response', function () { + test('structured agents with empty schemas fall back to a text response', function (): void { EmptySchemaStructuredAgent::fake([ new TextResponse('Hello', new Usage, new Meta), ]); @@ -131,74 +127,74 @@ }); }); -describe('stream responses', function () { - test('agent streams can be faked', function () { +describe('stream responses', function (): void { + test('agent streams can be faked', function (): void { AssistantAgent::fake([ 'First response', - fn (string $prompt) => 'Second response ('.$prompt.')', + fn (string $prompt): string => 'Second response ('.$prompt.')', new TextResponse('Third response', new Usage, new Meta), ]); $response = (new AssistantAgent)->stream('First prompt'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->text)->toEqual('First response') ->and($response->events)->toHaveCount(6); $response = (new AssistantAgent)->stream('Second prompt'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->text)->toEqual('Second response (Second prompt)') ->and($response->events)->toHaveCount(8); $response = (new AssistantAgent)->stream('Third prompt'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->text)->toEqual('Third response') ->and($response->events)->toHaveCount(6); }); - test('faked stream events share the response invocation id', function () { + test('faked stream events share the response invocation id', function (): void { AssistantAgent::fake(['Hello world']); $response = (new AssistantAgent)->stream('First prompt'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->events) ->each(fn ($event) => $event->invocationId->toBe($response->invocationId)); }); - test('faked empty response streams without text events', function () { + test('faked empty response streams without text events', function (): void { AssistantAgent::fake(['']); $response = (new AssistantAgent)->stream('First prompt'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->text)->toEqual('') ->and($response->events)->toHaveCount(2) - ->and(collect($response->events)->contains(fn ($event) => $event instanceof TextStart))->toBeFalse(); + ->and(collect($response->events)->contains(fn ($event): bool => $event instanceof TextStart))->toBeFalse(); }); - test('faked tool calls emit a tool call event while streaming', function () { + test('faked tool calls emit a tool call event while streaming', function (): void { MultiStepToolAgent::fake([ new ToolCall('call_123', 'FixedNumberGenerator', []), 'The number is 72019.', ]); $response = (new MultiStepToolAgent)->stream('Generate a number'); - $response->each(fn () => true); + $response->each(fn (): true => true); $events = collect($response->events); - $toolCall = $events->first(fn ($event) => $event instanceof ToolCallEvent); + $toolCall = $events->first(fn ($event): bool => $event instanceof ToolCallEvent); expect($toolCall)->not->toBeNull() ->and($toolCall->toolCall->name)->toBe('FixedNumberGenerator') - ->and($events->search(fn ($event) => $event instanceof ToolCallEvent)) - ->toBeLessThan($events->search(fn ($event) => $event instanceof ToolResultEvent)); + ->and($events->search(fn ($event): bool => $event instanceof ToolCallEvent)) + ->toBeLessThan($events->search(fn ($event): bool => $event instanceof ToolResultEvent)); }); }); -describe('queue responses', function () { - test('queued agents can be faked', function () { +describe('queue responses', function (): void { + test('queued agents can be faked', function (): void { AssistantAgent::fake(); (new AssistantAgent)->queue('First prompt'); @@ -206,34 +202,30 @@ AssistantAgent::assertQueued('First prompt'); AssistantAgent::assertNotQueued('Second prompt'); - AssistantAgent::assertQueued(function (QueuedAgentPrompt $prompt) { - return $prompt->prompt === 'First prompt'; - }); + AssistantAgent::assertQueued(fn (QueuedAgentPrompt $prompt): bool => $prompt->prompt === 'First prompt'); - AssistantAgent::assertNotQueued(function (QueuedAgentPrompt $prompt) { - return $prompt->prompt === 'Second prompt'; - }); + AssistantAgent::assertNotQueued(fn (QueuedAgentPrompt $prompt): bool => $prompt->prompt === 'Second prompt'); }); - test('can assert agent was never queued', function () { + test('can assert agent was never queued', function (): void { AssistantAgent::fake(); AssistantAgent::assertNeverQueued(); }); - test('assert queued does not throw undefined key when agent was never queued', function () { + test('assert queued does not throw undefined key when agent was never queued', function (): void { AssistantAgent::fake(); // Should fail the assertion gracefully, not throw an undefined array key error. try { AssistantAgent::assertQueued('Some prompt'); test()->fail('Expected assertion to fail.'); - } catch (AssertionFailedError $e) { - expect($e->getMessage())->toContain('An expected queued prompt was not received.'); + } catch (AssertionFailedError $assertionFailedError) { + expect($assertionFailedError->getMessage())->toContain('An expected queued prompt was not received.'); } }); - test('assert not queued does not throw undefined key when agent was never queued', function () { + test('assert not queued does not throw undefined key when agent was never queued', function (): void { AssistantAgent::fake(); // Should pass gracefully since the agent was never queued. @@ -241,79 +233,67 @@ }); }); -describe('provider enum support', function () { - test('queued agents accept ai provider enum', function () { +describe('provider enum support', function (): void { + test('queued agents accept ai provider enum', function (): void { AssistantAgent::fake(); (new AssistantAgent)->queue('Enum prompt', provider: Lab::OpenAI); - AssistantAgent::assertQueued(function (QueuedAgentPrompt $prompt) { - return $prompt->prompt === 'Enum prompt' - && $prompt->provider === Lab::OpenAI; - }); + AssistantAgent::assertQueued(fn (QueuedAgentPrompt $prompt): bool => $prompt->prompt === 'Enum prompt' + && $prompt->provider === Lab::OpenAI); }); - test('prompt accepts ai provider enum', function () { + test('prompt accepts ai provider enum', function (): void { AssistantAgent::fake(); (new AssistantAgent)->prompt('Enum prompt', provider: Lab::Anthropic); - AssistantAgent::assertPrompted(function (AgentPrompt $prompt) { - return $prompt->prompt === 'Enum prompt'; - }); + AssistantAgent::assertPrompted(fn (AgentPrompt $prompt): bool => $prompt->prompt === 'Enum prompt'); }); - test('stream accepts ai provider enum', function () { + test('stream accepts ai provider enum', function (): void { AssistantAgent::fake(); $response = (new AssistantAgent)->stream('Enum stream', provider: Lab::Gemini); - $response->each(fn () => true); + $response->each(fn (): true => true); - AssistantAgent::assertPrompted(function (AgentPrompt $prompt) { - return $prompt->prompt === 'Enum stream'; - }); + AssistantAgent::assertPrompted(fn (AgentPrompt $prompt): bool => $prompt->prompt === 'Enum stream'); }); }); -describe('timeout handling', function () { - test('timeout can be passed to agent prompt', function () { +describe('timeout handling', function (): void { + test('timeout can be passed to agent prompt', function (): void { AssistantAgent::fake(); $timeout = 120; (new AssistantAgent)->prompt('Test prompt', timeout: $timeout); - AssistantAgent::assertPrompted(function (AgentPrompt $prompt) { - return $prompt->prompt === 'Test prompt' - && $prompt->timeout === 120; - }); + AssistantAgent::assertPrompted(fn (AgentPrompt $prompt): bool => $prompt->prompt === 'Test prompt' + && $prompt->timeout === 120); }); - test('timeout defaults to sdk default when not provided', function () { + test('timeout defaults to sdk default when not provided', function (): void { AssistantAgent::fake(); (new AssistantAgent)->prompt('Test prompt'); - AssistantAgent::assertPrompted(function (AgentPrompt $prompt) { - return $prompt->prompt === 'Test prompt' - && $prompt->timeout === 60; - }); + AssistantAgent::assertPrompted(fn (AgentPrompt $prompt): bool => $prompt->prompt === 'Test prompt' + && $prompt->timeout === 60); }); - test('timeout can be passed to agent stream', function () { + test('timeout can be passed to agent stream', function (): void { AssistantAgent::fake(); $timeout = 120; (new AssistantAgent)->stream('Test prompt', timeout: $timeout); - AssistantAgent::assertPrompted(function (AgentPrompt $prompt) { - return $prompt->prompt === 'Test prompt' - && $prompt->timeout === 120; - }); + AssistantAgent::assertPrompted(fn (AgentPrompt $prompt): bool => $prompt->prompt === 'Test prompt' + && $prompt->timeout === 120); }); - test('timeout is preserved when revising agent prompt', function () { + test('timeout is preserved when revising agent prompt', function (): void { AssistantAgent::fake(); $prompt = new AgentPrompt( diff --git a/tests/Feature/AgentMiddlewareTest.php b/tests/Feature/AgentMiddlewareTest.php index cc338573d..f52758444 100644 --- a/tests/Feature/AgentMiddlewareTest.php +++ b/tests/Feature/AgentMiddlewareTest.php @@ -16,7 +16,7 @@ use Tests\Fixtures\Agents\RememberingAssistantAgent; use Tests\Fixtures\FakeConversationStore; -test('agent middleware is invoked', function () { +test('agent middleware is invoked', function (): void { AssistantAgent::fake([ 'Fake response', ]); @@ -31,7 +31,7 @@ unset($_SERVER['__testing.middleware-prompt']); }); -test('agent middleware is invoked when streaming', function () { +test('agent middleware is invoked when streaming', function (): void { AssistantAgent::fake([ 'Fake response', ]); @@ -41,8 +41,8 @@ ->stream('Test prompt'); $response - ->each(fn () => true) - ->then(function (StreamedAgentResponse $response) { + ->each(fn (): true => true) + ->then(function (StreamedAgentResponse $response): void { $_SERVER['__testing.text'] = $response->text; }); @@ -53,7 +53,7 @@ unset($_SERVER['__testing.middleware-prompt']); }); -test('agent prompted event receives prompt when middleware short circuits', function () { +test('agent prompted event receives prompt when middleware short circuits', function (): void { Event::fake(); AssistantAgent::fake([ @@ -64,13 +64,11 @@ ->withMiddleware([shortCircuitingMiddleware()]) ->prompt('Test prompt'); - Event::assertDispatched(AgentPrompted::class, function (AgentPrompted $event) { - return $event->prompt instanceof AgentPrompt - && $event->prompt->prompt === 'Test prompt'; - }); + Event::assertDispatched(AgentPrompted::class, fn (AgentPrompted $event): bool => $event->prompt instanceof AgentPrompt + && $event->prompt->prompt === 'Test prompt'); }); -test('agent streamed event receives prompt when middleware short circuits a stream', function () { +test('agent streamed event receives prompt when middleware short circuits a stream', function (): void { Event::fake(); AssistantAgent::fake([ @@ -85,13 +83,11 @@ // Drain the stream so the post-stream then() callback dispatches AgentStreamed. } - Event::assertDispatched(AgentStreamed::class, function (AgentStreamed $event) { - return $event->prompt instanceof AgentPrompt - && $event->prompt->prompt === 'Test prompt'; - }); + Event::assertDispatched(AgentStreamed::class, fn (AgentStreamed $event): bool => $event->prompt instanceof AgentPrompt + && $event->prompt->prompt === 'Test prompt'); }); -test('stream response conversation id is available after remembered conversations stream completes', function () { +test('stream response conversation id is available after remembered conversations stream completes', function (): void { app()->instance(ConversationStore::class, new FakeConversationStore); RememberingAssistantAgent::fake([ @@ -113,7 +109,7 @@ ->and($response->conversationUser)->toBe($user); }); -test('stream response conversation id is available when continuing an existing conversation', function () { +test('stream response conversation id is available when continuing an existing conversation', function (): void { app()->instance(ConversationStore::class, new FakeConversationStore); RememberingAssistantAgent::fake([ @@ -137,7 +133,7 @@ ->and($response->conversationUser)->toBe($user); }); -test('stream response conversation id syncs after late then callbacks', function () { +test('stream response conversation id syncs after late then callbacks', function (): void { AssistantAgent::fake([ 'Fake response', ]); @@ -153,7 +149,7 @@ expect($event)->not->toBeNull(); } - $response->then(function (StreamedAgentResponse $response) use ($user) { + $response->then(function (StreamedAgentResponse $response) use ($user): void { $response->withinConversation('late-conversation-id', $user); }); @@ -161,7 +157,7 @@ ->and($response->conversationUser)->toBe($user); }); -test('stream response preserves manually assigned conversation id without a participant', function () { +test('stream response preserves manually assigned conversation id without a participant', function (): void { AssistantAgent::fake([ 'Fake response', ]); @@ -182,7 +178,7 @@ function shortCircuitingMiddleware(): object { return new class { - public function handle(AgentPrompt $prompt, Closure $next) + public function handle(AgentPrompt $prompt, Closure $next): AgentResponse { return new AgentResponse( 'test-invocation-id', @@ -198,7 +194,7 @@ function streamingShortCircuitingMiddleware(): object { return new class { - public function handle(AgentPrompt $prompt, Closure $next) + public function handle(AgentPrompt $prompt, Closure $next): StreamableAgentResponse { return new StreamableAgentResponse( 'test-invocation-id', diff --git a/tests/Feature/AgentProviderOptionsTest.php b/tests/Feature/AgentProviderOptionsTest.php index 7c9d40cab..c96633579 100644 --- a/tests/Feature/AgentProviderOptionsTest.php +++ b/tests/Feature/AgentProviderOptionsTest.php @@ -6,7 +6,7 @@ use Tests\Fixtures\Agents\AssistantAgent; use Tests\Fixtures\Agents\ProviderOptionsAgent; -test('text generation options can extract provider options for openai', function () { +test('text generation options can extract provider options for openai', function (): void { $options = TextGenerationOptions::forAgent(new ProviderOptionsAgent); $providerOptions = $options->providerOptions(Lab::OpenAI); @@ -18,7 +18,7 @@ ], 'frequency_penalty' => 0.5, 'presence_penalty' => 0.3]); }); -test('text generation options can extract provider options for anthropic', function () { +test('text generation options can extract provider options for anthropic', function (): void { $options = TextGenerationOptions::forAgent(new ProviderOptionsAgent); $providerOptions = $options->providerOptions(Lab::Anthropic); @@ -30,7 +30,7 @@ ]]); }); -test('text generation options accept string provider', function () { +test('text generation options accept string provider', function (): void { $options = TextGenerationOptions::forAgent(new ProviderOptionsAgent); $providerOptions = $options->providerOptions('openai'); @@ -41,7 +41,7 @@ ], 'frequency_penalty' => 0.5, 'presence_penalty' => 0.3]); }); -test('text generation options return empty array for unknown provider', function () { +test('text generation options return empty array for unknown provider', function (): void { $options = TextGenerationOptions::forAgent(new ProviderOptionsAgent); $providerOptions = $options->providerOptions(Lab::Cohere); @@ -49,18 +49,18 @@ expect($providerOptions)->toBeEmpty(); }); -test('text generation options have null provider options when agent does not implement interface', function () { +test('text generation options have null provider options when agent does not implement interface', function (): void { $options = TextGenerationOptions::forAgent(new AssistantAgent); expect($options->providerOptions(Lab::OpenAI))->toBeNull(); }); -test('provider options are passed through when prompting', function () { +test('provider options are passed through when prompting', function (): void { ProviderOptionsAgent::fake(); (new ProviderOptionsAgent)->prompt('Hello'); - ProviderOptionsAgent::assertPrompted(function (AgentPrompt $prompt) { + ProviderOptionsAgent::assertPrompted(function (AgentPrompt $prompt): bool { $options = TextGenerationOptions::forAgent($prompt->agent); return $options->providerOptions(Lab::OpenAI) === [ @@ -73,12 +73,12 @@ }); }); -test('provider options default to null when not provided', function () { +test('provider options default to null when not provided', function (): void { AssistantAgent::fake(); (new AssistantAgent)->prompt('Hello'); - AssistantAgent::assertPrompted(function (AgentPrompt $prompt) { + AssistantAgent::assertPrompted(function (AgentPrompt $prompt): bool { $options = TextGenerationOptions::forAgent($prompt->agent); return $options->providerOptions(Lab::OpenAI) === null; diff --git a/tests/Feature/AgentStreamFailoverTest.php b/tests/Feature/AgentStreamFailoverTest.php index 9b5b6e786..946b10328 100644 --- a/tests/Feature/AgentStreamFailoverTest.php +++ b/tests/Feature/AgentStreamFailoverTest.php @@ -18,7 +18,7 @@ use Tests\Fixtures\ConversationStores\InMemoryConversationStore; use Tests\Fixtures\Providers\FakeStreamingProvider; -test('stream fails over to next provider when primary is rate limited', function () { +test('stream fails over to next provider when primary is rate limited', function (): void { Event::fake(); config([ @@ -48,10 +48,10 @@ Event::assertDispatched(AgentFailedOver::class); - Event::assertDispatched(AgentStreamed::class, fn (AgentStreamed $event) => $event->invocationId === $response->invocationId); + Event::assertDispatched(AgentStreamed::class, fn (AgentStreamed $event): bool => $event->invocationId === $response->invocationId); }); -test('stream throws last exception when all providers fail', function () { +test('stream throws last exception when all providers fail', function (): void { Event::fake(); config([ @@ -70,13 +70,13 @@ provider: ['primary', 'backup'], ); - expect(function () use ($response) { + expect(function () use ($response): void { foreach ($response as $_) { } })->toThrow(RateLimitedException::class); }); -test('stream then callback is invoked after failover', function () { +test('stream then callback is invoked after failover', function (): void { Event::fake(); config([ @@ -97,7 +97,7 @@ $thenResponse = null; - $response->then(function (StreamedAgentResponse $r) use (&$thenResponse) { + $response->then(function (StreamedAgentResponse $r) use (&$thenResponse): void { $thenResponse = $r; }); @@ -109,7 +109,7 @@ ->and($thenResponse->meta->provider)->toBe('backup'); }); -test('stream does not fail over when primary succeeds', function () { +test('stream does not fail over when primary succeeds', function (): void { Event::fake(); config([ @@ -135,7 +135,7 @@ Event::assertNotDispatched(AgentFailedOver::class); }); -test('single provider stream does not dispatch failover event when rate limited', function () { +test('single provider stream does not dispatch failover event when rate limited', function (): void { Event::fake(); config([ @@ -152,7 +152,7 @@ provider: 'primary', ); - expect(function () use ($response) { + expect(function () use ($response): void { foreach ($response as $_) { } })->toThrow(RateLimitedException::class); @@ -160,15 +160,15 @@ Event::assertNotDispatched(AgentFailedOver::class); }); -test('stream does not fail over when primary emits event then throws', function () { +test('stream does not fail over when primary emits event then throws', function (): void { Event::fake(); $manager = app(AiManager::class); - $manager->extend('mid_stream_failing', fn ($app, $config) => new FakeStreamingProvider( + $manager->extend('mid_stream_failing', fn ($app, $config): FakeStreamingProvider => new FakeStreamingProvider( $config, $app->make(Dispatcher::class), - fn ($provider, $prompt) => new StreamableAgentResponse( + fn ($provider, $prompt): StreamableAgentResponse => new StreamableAgentResponse( (string) Str::uuid7(), function () { yield (new TextDelta('m1', 'm1', 'partial', 0))->withInvocationId('inner-fail'); @@ -179,10 +179,10 @@ function () { ), )); - $manager->extend('working_backup', fn ($app, $config) => new FakeStreamingProvider( + $manager->extend('working_backup', fn ($app, $config): FakeStreamingProvider => new FakeStreamingProvider( $config, $app->make(Dispatcher::class), - fn ($provider, $prompt) => new StreamableAgentResponse( + fn ($provider, $prompt): StreamableAgentResponse => new StreamableAgentResponse( (string) Str::uuid7(), function () { yield (new TextDelta('m2', 'm2', 'World', 0))->withInvocationId('inner-success'); @@ -201,7 +201,7 @@ function () { provider: ['primary', 'backup'], ); - expect(function () use ($response) { + expect(function () use ($response): void { foreach ($response as $_) { } })->toThrow(RateLimitedException::class); @@ -209,28 +209,28 @@ function () { Event::assertNotDispatched(AgentFailedOver::class); }); -test('stream does not fail over when primary throws non failoverable exception', function () { +test('stream does not fail over when primary throws non failoverable exception', function (): void { Event::fake(); $manager = app(AiManager::class); $backupStreamed = false; - $manager->extend('malformed_stream', fn ($app, $config) => new FakeStreamingProvider( + $manager->extend('malformed_stream', fn ($app, $config): FakeStreamingProvider => new FakeStreamingProvider( $config, $app->make(Dispatcher::class), - fn ($provider, $prompt) => new StreamableAgentResponse( + fn ($provider, $prompt): StreamableAgentResponse => new StreamableAgentResponse( (string) Str::uuid7(), - function () { + function (): void { throw new InvalidArgumentException('Malformed stream response.'); }, new Meta($provider->name(), $prompt->model), ), )); - $manager->extend('backup_after_malformed_stream', fn ($app, $config) => new FakeStreamingProvider( + $manager->extend('backup_after_malformed_stream', fn ($app, $config): FakeStreamingProvider => new FakeStreamingProvider( $config, $app->make(Dispatcher::class), - function ($provider, $prompt) use (&$backupStreamed) { + function ($provider, $prompt) use (&$backupStreamed): StreamableAgentResponse { $backupStreamed = true; return new StreamableAgentResponse( @@ -253,7 +253,7 @@ function () { provider: ['primary', 'backup'], ); - expect(function () use ($response) { + expect(function () use ($response): void { foreach ($response as $_) { } })->toThrow(InvalidArgumentException::class, 'Malformed stream response.'); @@ -263,7 +263,7 @@ function () { Event::assertNotDispatched(AgentFailedOver::class); }); -test('stream conversation state survives failover', function () { +test('stream conversation state survives failover', function (): void { $store = new InMemoryConversationStore; $this->app->instance(ConversationStore::class, $store); @@ -287,7 +287,7 @@ function () { $thenResponse = null; - $response->then(function (StreamedAgentResponse $r) use (&$thenResponse) { + $response->then(function (StreamedAgentResponse $r) use (&$thenResponse): void { $thenResponse = $r; }); diff --git a/tests/Feature/AiManagerDefaultConfigTest.php b/tests/Feature/AiManagerDefaultConfigTest.php index a07edf6c9..a15ff560f 100644 --- a/tests/Feature/AiManagerDefaultConfigTest.php +++ b/tests/Feature/AiManagerDefaultConfigTest.php @@ -2,30 +2,31 @@ use Laravel\Ai\AiManager; use Laravel\Ai\Providers\Provider; +use Laravel\Ai\Responses\AgentResponse; use function Laravel\Ai\agent; -test('string default works', function () { +test('string default works', function (): void { config(['ai.default' => 'openai']); expect(app(AiManager::class)->getDefaultInstance())->toBe('openai'); }); -test('array default via Ai facade throws helpful error', function () { +test('array default via Ai facade throws helpful error', function (): void { config(['ai.default' => ['text' => 'anthropic']]); expect(fn () => app(AiManager::class)->getDefaultInstance()) ->toThrow(InvalidArgumentException::class, 'must be a string provider name or a Lab enum, not an array'); }); -test('array default via agent prompt throws helpful error', function () { +test('array default via agent prompt throws helpful error', function (): void { config(['ai.default' => ['text' => 'anthropic']]); - expect(fn () => agent(instructions: 'test')->prompt('hello')) + expect(fn (): AgentResponse => agent(instructions: 'test')->prompt('hello')) ->toThrow(InvalidArgumentException::class, 'must be a string provider name or a Lab enum, not an array'); }); -test('failover provider list still works', function () { +test('failover provider list still works', function (): void { expect(Provider::formatProviderAndModelList(['anthropic' => 'claude-3-5-sonnet', 'openai' => 'gpt-4'])) ->toBe(['anthropic' => 'claude-3-5-sonnet', 'openai' => 'gpt-4']); }); diff --git a/tests/Feature/AiManagerTest.php b/tests/Feature/AiManagerTest.php index 27474f511..076ba3e29 100644 --- a/tests/Feature/AiManagerTest.php +++ b/tests/Feature/AiManagerTest.php @@ -3,27 +3,28 @@ use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Support\Facades\Facade; use Laravel\Ai\Ai; +use Laravel\Ai\Contracts\Providers\TextProvider; use Laravel\Ai\Gateway\OpenAi\OpenAiGateway; use Laravel\Ai\Providers\OpenAiProvider; -test('can get an openai provider instance', function () { +test('can get an openai provider instance', function (): void { expect(Ai::textProvider('openai'))->toBeInstanceOf(OpenAiProvider::class); }); -test('provider type is ensured', function () { +test('provider type is ensured', function (): void { Ai::audioProvider('anthropic'); })->throws(LogicException::class); -test('driver extensions survive between queue jobs', function () { +test('driver extensions survive between queue jobs', function (): void { config()->set('ai.providers.custom', ['driver' => 'custom']); - Ai::extend('custom', fn ($app, array $config) => new OpenAiProvider( + Ai::extend('custom', fn ($app, array $config): OpenAiProvider => new OpenAiProvider( $app->make(OpenAiGateway::class), $config, $app->make(Dispatcher::class), )); - $runJobOnFreshScope = function () { + $runJobOnFreshScope = function (): TextProvider { app()->forgetScopedInstances(); Facade::clearResolvedInstances(); diff --git a/tests/Feature/AudioFakeTest.php b/tests/Feature/AudioFakeTest.php index bc9ab531e..e99cda57d 100644 --- a/tests/Feature/AudioFakeTest.php +++ b/tests/Feature/AudioFakeTest.php @@ -9,22 +9,22 @@ use Laravel\Ai\Responses\AudioResponse; use Laravel\Ai\Responses\Data\Meta; -test('audio rejects empty text', function () { +test('audio rejects empty text', function (): void { Audio::fake(); Audio::of('')->generate(); })->throws(InvalidArgumentException::class, 'Text content is required to generate audio.'); -test('audio rejects whitespace-only text', function () { +test('audio rejects whitespace-only text', function (): void { Audio::fake(); Audio::of(" \t\n")->generate(); })->throws(InvalidArgumentException::class, 'Text content is required to generate audio.'); -test('audio can be faked', function () { +test('audio can be faked', function (): void { Audio::fake([ base64_encode('first-audio'), - fn (AudioPrompt $prompt) => base64_encode('second-audio-'.$prompt->text), + fn (AudioPrompt $prompt): string => base64_encode('second-audio-'.$prompt->text), new AudioResponse(base64_encode('third-audio'), new Meta), ]); @@ -38,21 +38,19 @@ expect($response->audio)->toEqual(base64_encode('third-audio')); // Assertion tests... - Audio::assertGenerated(fn (AudioPrompt $prompt) => $prompt->text === 'First text'); - Audio::assertNotGenerated(fn (AudioPrompt $prompt) => $prompt->text === 'Missing text'); + Audio::assertGenerated(fn (AudioPrompt $prompt): bool => $prompt->text === 'First text'); + Audio::assertNotGenerated(fn (AudioPrompt $prompt): bool => $prompt->text === 'Missing text'); - Audio::assertGenerated(function (AudioPrompt $prompt) { - return $prompt->text === 'First text'; - }); + Audio::assertGenerated(fn (AudioPrompt $prompt): bool => $prompt->text === 'First text'); }); -test('can assert no audio was generated', function () { +test('can assert no audio was generated', function (): void { Audio::fake(); Audio::assertNothingGenerated(); }); -test('audio can be faked with no predefined responses', function () { +test('audio can be faked with no predefined responses', function (): void { Audio::fake(); $response = Audio::of('First text')->generate(); @@ -62,10 +60,8 @@ expect($response->audio)->toEqual(base64_encode('fake-audio-content')); }); -test('audio can be faked with a single closure that is invoked for every generation', function () { - Audio::fake(function (AudioPrompt $prompt) { - return base64_encode('audio-for-'.$prompt->text); - }); +test('audio can be faked with a single closure that is invoked for every generation', function (): void { + Audio::fake(fn (AudioPrompt $prompt): string => base64_encode('audio-for-'.$prompt->text)); $response = Audio::of('First text')->generate(); expect($response->audio)->toEqual(base64_encode('audio-for-First text')); @@ -74,16 +70,16 @@ expect($response->audio)->toEqual(base64_encode('audio-for-Second text')); }); -test('audio timeout defaults to sdk fallback', function () { +test('audio timeout defaults to sdk fallback', function (): void { Audio::fake(); Audio::of('Hello world')->generate(); - Audio::assertGenerated(fn (AudioPrompt $prompt) => $prompt->timeout === 30); + Audio::assertGenerated(fn (AudioPrompt $prompt): bool => $prompt->timeout === 30); }); -test('fake audio closure receives timeout', function () { - Audio::fake(function (AudioPrompt $prompt) { +test('fake audio closure receives timeout', function (): void { + Audio::fake(function (AudioPrompt $prompt): string { expect($prompt->timeout)->toBe(45); return base64_encode('audio-for-'.$prompt->text); @@ -92,17 +88,17 @@ Audio::of('Hello world')->timeout(45)->generate(); }); -test('audio can be generated from stringable macro', function () { +test('audio can be generated from stringable macro', function (): void { Audio::fake(); $response = Str::of('Hello world')->toAudio(); expect($response->audio)->toEqual(base64_encode('fake-audio-content')); - Audio::assertGenerated(fn (AudioPrompt $prompt) => $prompt->text === 'Hello world'); + Audio::assertGenerated(fn (AudioPrompt $prompt): bool => $prompt->text === 'Hello world'); }); -test('stringable audio macro passes through options', function () { +test('stringable audio macro passes through options', function (): void { Audio::fake(); Str::of('Hello world')->toAudio( @@ -113,100 +109,88 @@ timeout: 45, ); - Audio::assertGenerated(function (AudioPrompt $prompt) { - return $prompt->text === 'Hello world' - && $prompt->provider instanceof ElevenLabsProvider - && $prompt->voice === 'alloy' - && $prompt->instructions === 'Speak slowly' - && $prompt->model === 'custom-model' - && $prompt->timeout === 45; - }); + Audio::assertGenerated(fn (AudioPrompt $prompt): bool => $prompt->text === 'Hello world' + && $prompt->provider instanceof ElevenLabsProvider + && $prompt->voice === 'alloy' + && $prompt->instructions === 'Speak slowly' + && $prompt->model === 'custom-model' + && $prompt->timeout === 45); }); -test('audio can prevent stray generations', function () { +test('audio can prevent stray generations', function (): void { Audio::fake()->preventStrayAudio(); Audio::of('First text')->generate(); })->throws(RuntimeException::class); -test('fake closures can throw exceptions', function () { - Audio::fake(function () { +test('fake closures can throw exceptions', function (): void { + Audio::fake(function (): void { throw new Exception('Something went wrong'); }); Audio::of('Test text')->generate(); })->throws(Exception::class); -test('audio voice and instructions are recorded', function () { +test('audio voice and instructions are recorded', function (): void { Audio::fake(); Audio::of('Hello world')->voice('alloy')->instructions('Speak slowly')->generate(); - Audio::assertGenerated(function (AudioPrompt $prompt) { - return $prompt->text === 'Hello world' - && $prompt->voice === 'alloy' - && $prompt->instructions === 'Speak slowly'; - }); + Audio::assertGenerated(fn (AudioPrompt $prompt): bool => $prompt->text === 'Hello world' + && $prompt->voice === 'alloy' + && $prompt->instructions === 'Speak slowly'); }); -test('queued audio can be faked', function () { +test('queued audio can be faked', function (): void { Audio::fake(); Audio::of('First text')->queue(); - Audio::assertQueued(fn (QueuedAudioPrompt $prompt) => $prompt->text === 'First text'); - Audio::assertNotQueued(fn (QueuedAudioPrompt $prompt) => $prompt->contains('Second text')); + Audio::assertQueued(fn (QueuedAudioPrompt $prompt): bool => $prompt->text === 'First text'); + Audio::assertNotQueued(fn (QueuedAudioPrompt $prompt): bool => $prompt->contains('Second text')); - Audio::assertQueued(function (QueuedAudioPrompt $prompt) { - return $prompt->text === 'First text'; - }); + Audio::assertQueued(fn (QueuedAudioPrompt $prompt): bool => $prompt->text === 'First text'); - Audio::assertNotQueued(function (QueuedAudioPrompt $prompt) { - return $prompt->text === 'Second text'; - }); + Audio::assertNotQueued(fn (QueuedAudioPrompt $prompt): bool => $prompt->text === 'Second text'); }); -test('can assert no audio was queued', function () { +test('can assert no audio was queued', function (): void { Audio::fake(); Audio::assertNothingQueued(); }); -test('generate accepts ai provider enum', function () { +test('generate accepts ai provider enum', function (): void { Audio::fake(); Audio::of('Enum audio')->generate(provider: Lab::OpenAI); - Audio::assertGenerated(fn (AudioPrompt $prompt) => $prompt->text === 'Enum audio'); + Audio::assertGenerated(fn (AudioPrompt $prompt): bool => $prompt->text === 'Enum audio'); }); -test('queued audio accepts ai provider enum', function () { +test('queued audio accepts ai provider enum', function (): void { Audio::fake(); Audio::of('Queued enum audio')->queue(provider: Lab::ElevenLabs); - Audio::assertQueued(fn (QueuedAudioPrompt $prompt) => $prompt->text === 'Queued enum audio' + Audio::assertQueued(fn (QueuedAudioPrompt $prompt): bool => $prompt->text === 'Queued enum audio' && $prompt->provider === Lab::ElevenLabs); }); -test('queued audio voice and instructions are recorded', function () { +test('queued audio voice and instructions are recorded', function (): void { Audio::fake(); Audio::of('Hello world')->male()->instructions('Speak quickly')->queue(); - Audio::assertQueued(function (QueuedAudioPrompt $prompt) { - return $prompt->text === 'Hello world' - && $prompt->voice === 'default-male' - && $prompt->instructions === 'Speak quickly'; - }); + Audio::assertQueued(fn (QueuedAudioPrompt $prompt): bool => $prompt->text === 'Hello world' + && $prompt->voice === 'default-male' + && $prompt->instructions === 'Speak quickly'); }); -test('queued audio timeout is recorded', function () { +test('queued audio timeout is recorded', function (): void { Audio::fake(); Audio::of('Hello world')->timeout(90)->queue(); - Audio::assertQueued(function (QueuedAudioPrompt $prompt) { - return $prompt->timeout === 90 && $prompt->contains('Hello world'); - }); + Audio::assertQueued(fn (QueuedAudioPrompt $prompt): bool => $prompt->timeout === 90 && $prompt->contains('Hello world')); }); diff --git a/tests/Feature/BroadcastAgentTest.php b/tests/Feature/BroadcastAgentTest.php index ba4317723..1d1bf6e4e 100644 --- a/tests/Feature/BroadcastAgentTest.php +++ b/tests/Feature/BroadcastAgentTest.php @@ -9,7 +9,7 @@ use Laravel\Ai\Responses\StreamedAgentResponse; use Tests\Fixtures\Agents\AssistantAgent; -test('then callback receives streamed agent response', function () { +test('then callback receives streamed agent response', function (): void { Event::fake(); AssistantAgent::fake(['Hello world']); @@ -21,7 +21,7 @@ channels: new Channel('test-channel'), ); - $job->then(function ($response) use (&$received) { + $job->then(function ($response) use (&$received): void { $received = $response; }); @@ -32,7 +32,7 @@ ->and($received->text)->toBe('Hello world'); }); -test('multiple then callbacks all receive streamed agent response', function () { +test('multiple then callbacks all receive streamed agent response', function (): void { Event::fake(); AssistantAgent::fake(['Hello world']); @@ -45,11 +45,11 @@ channels: new Channel('test-channel'), ); - $job->then(function ($response) use (&$receivedA) { + $job->then(function ($response) use (&$receivedA): void { $receivedA = $response; }); - $job->then(function ($response) use (&$receivedB) { + $job->then(function ($response) use (&$receivedB): void { $receivedB = $response; }); @@ -59,7 +59,7 @@ ->and($receivedB)->toBeInstanceOf(StreamedAgentResponse::class); }); -test('failed broadcasts a stream_failed event with recoverable false on the configured channel', function () { +test('failed broadcasts a stream_failed event with recoverable false on the configured channel', function (): void { Event::fake(); $channel = new Channel('test-channel'); @@ -75,7 +75,7 @@ $job->failed(new RuntimeException('Something went wrong')); - Event::assertDispatched(AnonymousEvent::class, function (AnonymousEvent $event) use ($channel, $invocationId) { + Event::assertDispatched(AnonymousEvent::class, function (AnonymousEvent $event) use ($channel, $invocationId): bool { $payload = $event->broadcastWith(); return $event->broadcastAs() === 'stream_failed' @@ -86,7 +86,7 @@ }); }); -test('failed broadcasts on every channel when given an array', function () { +test('failed broadcasts on every channel when given an array', function (): void { Event::fake(); $channels = [new Channel('a'), new Channel('b')]; @@ -99,13 +99,11 @@ $job->failed(new RuntimeException('boom')); - Event::assertDispatched(AnonymousEvent::class, function (AnonymousEvent $event) use ($channels) { - return $event->broadcastAs() === 'stream_failed' - && $event->broadcastOn() === $channels; - }); + Event::assertDispatched(AnonymousEvent::class, fn (AnonymousEvent $event): bool => $event->broadcastAs() === 'stream_failed' + && $event->broadcastOn() === $channels); }); -test('failed event shares the invocation id with broadcasts from handle', function () { +test('failed event shares the invocation id with broadcasts from handle', function (): void { Event::fake(); AssistantAgent::fake(['Hello world']); @@ -121,7 +119,7 @@ $broadcastIds = []; - Event::assertDispatched(AnonymousEvent::class, function (AnonymousEvent $event) use (&$broadcastIds) { + Event::assertDispatched(AnonymousEvent::class, function (AnonymousEvent $event) use (&$broadcastIds): true { $broadcastIds[] = $event->broadcastWith()['invocation_id'] ?? null; return true; @@ -130,7 +128,7 @@ expect(array_values(array_unique($broadcastIds)))->toBe([$invocationId]); }); -test('an oversized broadcast frame does not abort the stream and then still resolves', function () { +test('an oversized broadcast frame does not abort the stream and then still resolves', function (): void { AssistantAgent::fake(['Hello world']); $pending = Mockery::mock(AnonymousEvent::class); @@ -148,7 +146,7 @@ channels: new Channel('test-channel'), ); - $job->then(function ($response) use (&$received) { + $job->then(function ($response) use (&$received): void { $received = $response; }); @@ -159,7 +157,7 @@ ->and($received->text)->toBe('Hello world'); }); -test('streamed response passed to then is fully resolved', function () { +test('streamed response passed to then is fully resolved', function (): void { Event::fake(); AssistantAgent::fake(['Hello world']); @@ -171,7 +169,7 @@ channels: new Channel('test-channel'), ); - $job->then(function ($response) use (&$received) { + $job->then(function ($response) use (&$received): void { $received = $response; }); diff --git a/tests/Feature/Console/MakeAgentCommandTest.php b/tests/Feature/Console/MakeAgentCommandTest.php index 5d86001ab..d1158fbf5 100644 --- a/tests/Feature/Console/MakeAgentCommandTest.php +++ b/tests/Feature/Console/MakeAgentCommandTest.php @@ -1,6 +1,6 @@ artisan('make:agent', [ 'name' => 'TestAgent', ]); @@ -10,7 +10,7 @@ expect(app_path('Ai/Agents/TestAgent.php'))->toBeFile(); }); -test('can create a structured agent class', function () { +test('can create a structured agent class', function (): void { $response = $this->artisan('make:agent', [ 'name' => 'StructuredAgent', '--structured' => true, @@ -21,7 +21,7 @@ expect(app_path('Ai/Agents/StructuredAgent.php'))->toBeFile(); }); -test('may publish custom agent stubs', function () { +test('may publish custom agent stubs', function (): void { $this->artisan('vendor:publish', [ '--tag' => 'ai-stubs', '--force' => true, diff --git a/tests/Feature/Console/MakeAgentMiddlewareCommandTest.php b/tests/Feature/Console/MakeAgentMiddlewareCommandTest.php index d28b9a18a..da7e966cf 100644 --- a/tests/Feature/Console/MakeAgentMiddlewareCommandTest.php +++ b/tests/Feature/Console/MakeAgentMiddlewareCommandTest.php @@ -1,6 +1,6 @@ artisan('make:agent-middleware', [ 'name' => 'TestMiddleware', ]); @@ -10,7 +10,7 @@ expect(app_path('Ai/Middleware/TestMiddleware.php'))->toBeFile(); }); -test('may publish custom middleware stub', function () { +test('may publish custom middleware stub', function (): void { $this->artisan('vendor:publish', [ '--tag' => 'ai-stubs', '--force' => true, diff --git a/tests/Feature/Console/MakeToolCommandTest.php b/tests/Feature/Console/MakeToolCommandTest.php index 5f5ae7ad7..e151cf5f0 100644 --- a/tests/Feature/Console/MakeToolCommandTest.php +++ b/tests/Feature/Console/MakeToolCommandTest.php @@ -1,6 +1,6 @@ artisan('make:tool', [ 'name' => 'TestTool', ]); @@ -10,7 +10,7 @@ expect(app_path('Ai/Tools/TestTool.php'))->toBeFile(); }); -test('may publish custom tool stub', function () { +test('may publish custom tool stub', function (): void { $this->artisan('vendor:publish', [ '--tag' => 'ai-stubs', '--force' => true, diff --git a/tests/Feature/ConversationRelationshipTest.php b/tests/Feature/ConversationRelationshipTest.php index 22436b8b9..f3c6a8008 100644 --- a/tests/Feature/ConversationRelationshipTest.php +++ b/tests/Feature/ConversationRelationshipTest.php @@ -8,8 +8,8 @@ use Laravel\Ai\Concerns\HasConversations; use Laravel\Ai\Models\Conversation; -uses(RefreshDatabase::class)->beforeEach(function () { - Schema::create('users', function (Blueprint $table) { +uses(RefreshDatabase::class)->beforeEach(function (): void { + Schema::create('users', function (Blueprint $table): void { $table->id(); $table->string('name'); $table->timestamps(); @@ -21,7 +21,7 @@ ])->run(); })->in(__FILE__); -test('model can retrieve conversations using relationship', function () { +test('model can retrieve conversations using relationship', function (): void { $user = ConversationRelationshipUser::create(['name' => 'Taylor']); $otherUser = ConversationRelationshipUser::create(['name' => 'Abigail']); @@ -56,7 +56,7 @@ ->and($conversations->first())->toBeInstanceOf(Conversation::class); }); -test('conversation can retrieve messages using relationship', function () { +test('conversation can retrieve messages using relationship', function (): void { $user = ConversationRelationshipUser::create(['name' => 'Taylor']); $conversation = Conversation::create([ @@ -88,7 +88,7 @@ ->and($conversation->messages->first()->attachments)->toBeArray(); }); -test('conversation model uses configured database connection', function () { +test('conversation model uses configured database connection', function (): void { config(['database.connections.secondary' => [ 'driver' => 'sqlite', 'database' => ':memory:', @@ -96,7 +96,7 @@ 'foreign_key_constraints' => true, ]]); - Schema::connection('secondary')->create('agent_conversations', function (Blueprint $table) { + Schema::connection('secondary')->create('agent_conversations', function (Blueprint $table): void { $table->string('id', 36)->primary(); $table->foreignId('user_id')->nullable(); $table->string('title'); @@ -118,7 +118,7 @@ expect($conversation)->not->toBeNull() ->and($conversation->title)->toBe('On Secondary DB') ->and(DB::table('agent_conversations')->where('id', 'secondary-conversation-1')->exists())->toBeFalse(); -})->after(function () { +})->after(function (): void { config(['ai.conversations.connection' => null]); }); diff --git a/tests/Feature/EmbeddingsCacheTest.php b/tests/Feature/EmbeddingsCacheTest.php index 98f1efd75..001bf014f 100644 --- a/tests/Feature/EmbeddingsCacheTest.php +++ b/tests/Feature/EmbeddingsCacheTest.php @@ -3,7 +3,7 @@ use Illuminate\Support\Facades\Http; use Laravel\Ai\Embeddings; -beforeEach(function () { +beforeEach(function (): void { config([ 'ai.providers.cohere' => [...config('ai.providers.cohere'), 'key' => 'test-key'], 'ai.default_for_embeddings' => 'cohere', @@ -18,14 +18,14 @@ ]); }); -test('cache is used when enabled explicitly', function () { +test('cache is used when enabled explicitly', function (): void { Embeddings::for(['Hello'])->cache(3600)->generate(provider: 'cohere', model: 'embed-v4.0'); Embeddings::for(['Hello'])->cache(3600)->generate(provider: 'cohere', model: 'embed-v4.0'); expect(Http::recorded())->toHaveCount(1); }); -test('cache is used when enabled globally via config', function () { +test('cache is used when enabled globally via config', function (): void { config(['ai.caching.embeddings.cache' => true]); Embeddings::for(['Hello'])->generate(provider: 'cohere', model: 'embed-v4.0'); @@ -34,7 +34,7 @@ expect(Http::recorded())->toHaveCount(1); }); -test('zero cache seconds bypasses an existing cached entry', function () { +test('zero cache seconds bypasses an existing cached entry', function (): void { Embeddings::for(['Hello'])->cache(3600)->generate(provider: 'cohere', model: 'embed-v4.0'); expect(Http::recorded())->toHaveCount(1); @@ -44,7 +44,7 @@ expect(Http::recorded())->toHaveCount(2); }); -test('negative cache seconds bypasses an existing cached entry', function () { +test('negative cache seconds bypasses an existing cached entry', function (): void { Embeddings::for(['Hello'])->cache(3600)->generate(provider: 'cohere', model: 'embed-v4.0'); expect(Http::recorded())->toHaveCount(1); @@ -54,7 +54,7 @@ expect(Http::recorded())->toHaveCount(2); }); -test('toEmbeddings honors cache false even when enabled globally', function () { +test('toEmbeddings honors cache false even when enabled globally', function (): void { config(['ai.caching.embeddings.cache' => true]); str('hello world')->toEmbeddings(provider: 'cohere', model: 'embed-v4.0', cache: false); @@ -63,7 +63,7 @@ expect(Http::recorded())->toHaveCount(2); }); -test('toEmbeddings uses cache when enabled globally', function () { +test('toEmbeddings uses cache when enabled globally', function (): void { config(['ai.caching.embeddings.cache' => true]); str('hello world')->toEmbeddings(provider: 'cohere', model: 'embed-v4.0'); diff --git a/tests/Feature/EmbeddingsFakeTest.php b/tests/Feature/EmbeddingsFakeTest.php index 9a1695dba..fd7ecadf0 100644 --- a/tests/Feature/EmbeddingsFakeTest.php +++ b/tests/Feature/EmbeddingsFakeTest.php @@ -5,44 +5,44 @@ use Laravel\Ai\Prompts\EmbeddingsPrompt; use Laravel\Ai\Prompts\QueuedEmbeddingsPrompt; -test('embeddings reject empty input list', function () { +test('embeddings reject empty input list', function (): void { Embeddings::fake(); Embeddings::for([])->generate(); })->throws(InvalidArgumentException::class, 'At least one input is required to generate embeddings.'); -test('embeddings reject associative input array', function () { +test('embeddings reject associative input array', function (): void { Embeddings::fake(); Embeddings::for(['first' => 'Hello world'])->generate(); })->throws(InvalidArgumentException::class, 'Inputs to embed must be a list, not an associative array.'); -test('embeddings reject blank string inputs', function () { +test('embeddings reject blank string inputs', function (): void { Embeddings::fake(); Embeddings::for([''])->generate(); })->throws(InvalidArgumentException::class, 'The input at index 0 must be a non-blank string.'); -test('embeddings reject whitespace-only string inputs', function () { +test('embeddings reject whitespace-only string inputs', function (): void { Embeddings::fake(); Embeddings::for([" \t\n"])->generate(); })->throws(InvalidArgumentException::class, 'The input at index 0 must be a non-blank string.'); -test('embeddings reject non-string inputs', function () { +test('embeddings reject non-string inputs', function (): void { Embeddings::fake(); Embeddings::for([123])->generate(); })->throws(InvalidArgumentException::class, 'The input at index 0 must be a non-blank string.'); -test('embeddings report the offending index for blank inputs', function () { +test('embeddings report the offending index for blank inputs', function (): void { Embeddings::fake(); Embeddings::for(['valid', 'also valid', ''])->generate(); })->throws(InvalidArgumentException::class, 'The input at index 2 must be a non-blank string.'); -describe('generating embeddings', function () { - test('can fake embeddings', function () { +describe('generating embeddings', function (): void { + test('can fake embeddings', function (): void { Embeddings::fake(); $response = Embeddings::for(['Hello world'])->generate(); @@ -51,7 +51,7 @@ ->and($response->first())->toHaveCount(1536); }); - test('can fake embeddings with custom dimensions', function () { + test('can fake embeddings with custom dimensions', function (): void { Embeddings::fake(); $response = Embeddings::for(['Hello world'])->dimensions(512)->generate(); @@ -60,7 +60,7 @@ ->and($response->first())->toHaveCount(512); }); - test('can fake embeddings with multiple inputs', function () { + test('can fake embeddings with multiple inputs', function (): void { Embeddings::fake(); $response = Embeddings::for(['Hello', 'World', 'Test'])->generate(); @@ -90,7 +90,7 @@ ]); }); - test('can fake embeddings with custom response', function () { + test('can fake embeddings with custom response', function (): void { $customEmbedding = array_fill(0, 100, 0.5); Embeddings::fake([ @@ -102,13 +102,11 @@ expect($response->first())->toEqual($customEmbedding); }); - test('can fake embeddings with closure', function () { - Embeddings::fake(function (EmbeddingsPrompt $prompt) { - return array_map( - fn () => array_fill(0, $prompt->dimensions, 0.1), - $prompt->inputs - ); - }); + test('can fake embeddings with closure', function (): void { + Embeddings::fake(fn (EmbeddingsPrompt $prompt): array => array_map( + fn (): array => array_fill(0, $prompt->dimensions, 0.1), + $prompt->inputs + )); $response = Embeddings::for(['Hello', 'World'])->dimensions(256)->generate(); @@ -116,20 +114,20 @@ ->and($response->first())->toHaveCount(256); }); - test('embeddings timeout defaults to sdk fallback', function () { + test('embeddings timeout defaults to sdk fallback', function (): void { Embeddings::fake(); Embeddings::for(['Hello world'])->generate(); - Embeddings::assertGenerated(fn (EmbeddingsPrompt $prompt) => $prompt->timeout === 30); + Embeddings::assertGenerated(fn (EmbeddingsPrompt $prompt): bool => $prompt->timeout === 30); }); - test('fake embeddings closure receives timeout', function () { - Embeddings::fake(function (EmbeddingsPrompt $prompt) { + test('fake embeddings closure receives timeout', function (): void { + Embeddings::fake(function (EmbeddingsPrompt $prompt): array { expect($prompt->timeout)->toBe(45); return array_map( - fn () => array_fill(0, $prompt->dimensions, 0.1), + fn (): array => array_fill(0, $prompt->dimensions, 0.1), $prompt->inputs ); }); @@ -137,7 +135,7 @@ Embeddings::for(['Hello world'])->timeout(45)->generate(); }); - test('fake embeddings prompt carries provider options', function () { + test('fake embeddings prompt carries provider options', function (): void { Embeddings::fake(); Embeddings::for(['Hello']) @@ -145,11 +143,11 @@ ->generate(); Embeddings::assertGenerated( - fn (EmbeddingsPrompt $prompt) => $prompt->providerOptions === ['input_type' => 'search_query'], + fn (EmbeddingsPrompt $prompt): bool => $prompt->providerOptions === ['input_type' => 'search_query'], ); }); - test('fake queued embeddings prompt carries provider options', function () { + test('fake queued embeddings prompt carries provider options', function (): void { Embeddings::fake(); Embeddings::for(['Hello']) @@ -157,118 +155,104 @@ ->queue(); Embeddings::assertQueued( - fn (QueuedEmbeddingsPrompt $prompt) => $prompt->providerOptions === ['input_type' => 'search_query'], + fn (QueuedEmbeddingsPrompt $prompt): bool => $prompt->providerOptions === ['input_type' => 'search_query'], ); }); - test('fake embeddings are normalized', function () { + test('fake embeddings are normalized', function (): void { $embedding = Embeddings::fakeEmbedding(100); // Check it has the right dimensions... expect($embedding)->toHaveCount(100); // Check it's normalized (magnitude ~= 1)... - $magnitude = sqrt(array_sum(array_map(fn ($v) => $v * $v, $embedding))); + $magnitude = sqrt(array_sum(array_map(fn ($v): int|float => $v * $v, $embedding))); expect($magnitude)->toEqualWithDelta(1.0, 0.0001); }); - test('can prevent stray embeddings generations', function () { + test('can prevent stray embeddings generations', function (): void { Embeddings::fake()->preventStrayEmbeddings(); Embeddings::for(['Hello world'])->generate(); })->throws(RuntimeException::class); }); -describe('assertions', function () { - test('can assert embeddings generated', function () { +describe('assertions', function (): void { + test('can assert embeddings generated', function (): void { Embeddings::fake(); Embeddings::for(['Hello world'])->generate(); - Embeddings::assertGenerated(function (EmbeddingsPrompt $prompt) { - return in_array('Hello world', $prompt->inputs); - }); + Embeddings::assertGenerated(fn (EmbeddingsPrompt $prompt): bool => in_array('Hello world', $prompt->inputs)); }); - test('can assert embeddings not generated', function () { + test('can assert embeddings not generated', function (): void { Embeddings::fake(); Embeddings::for(['Hello world'])->generate(); - Embeddings::assertNotGenerated(function (EmbeddingsPrompt $prompt) { - return in_array('Goodbye', $prompt->inputs); - }); + Embeddings::assertNotGenerated(fn (EmbeddingsPrompt $prompt): bool => in_array('Goodbye', $prompt->inputs)); }); - test('can assert nothing generated', function () { + test('can assert nothing generated', function (): void { Embeddings::fake(); Embeddings::assertNothingGenerated(); }); }); -describe('queued embeddings', function () { - test('queued embeddings can be faked', function () { +describe('queued embeddings', function (): void { + test('queued embeddings can be faked', function (): void { Embeddings::fake(); Embeddings::for(['Hello world'])->queue(); - Embeddings::assertQueued(fn (QueuedEmbeddingsPrompt $prompt) => $prompt->contains('Hello')); - Embeddings::assertNotQueued(fn (QueuedEmbeddingsPrompt $prompt) => $prompt->contains('Goodbye')); + Embeddings::assertQueued(fn (QueuedEmbeddingsPrompt $prompt): bool => $prompt->contains('Hello')); + Embeddings::assertNotQueued(fn (QueuedEmbeddingsPrompt $prompt): bool => $prompt->contains('Goodbye')); - Embeddings::assertQueued(function (QueuedEmbeddingsPrompt $prompt) { - return in_array('Hello world', $prompt->inputs); - }); + Embeddings::assertQueued(fn (QueuedEmbeddingsPrompt $prompt): bool => in_array('Hello world', $prompt->inputs)); - Embeddings::assertNotQueued(function (QueuedEmbeddingsPrompt $prompt) { - return in_array('Goodbye', $prompt->inputs); - }); + Embeddings::assertNotQueued(fn (QueuedEmbeddingsPrompt $prompt): bool => in_array('Goodbye', $prompt->inputs)); }); - test('can assert no embeddings were queued', function () { + test('can assert no embeddings were queued', function (): void { Embeddings::fake(); Embeddings::assertNothingQueued(); }); - test('queued embeddings dimensions are recorded', function () { + test('queued embeddings dimensions are recorded', function (): void { Embeddings::fake(); Embeddings::for(['Hello world'])->dimensions(256)->queue(); - Embeddings::assertQueued(function (QueuedEmbeddingsPrompt $prompt) { - return $prompt->dimensions === 256 && $prompt->count() === 1; - }); + Embeddings::assertQueued(fn (QueuedEmbeddingsPrompt $prompt): bool => $prompt->dimensions === 256 && $prompt->count() === 1); }); - test('queued embeddings timeout is recorded', function () { + test('queued embeddings timeout is recorded', function (): void { Embeddings::fake(); Embeddings::for(['Hello world'])->timeout(90)->queue(); - Embeddings::assertQueued(function (QueuedEmbeddingsPrompt $prompt) { - return $prompt->timeout === 90 && $prompt->count() === 1; - }); + Embeddings::assertQueued(fn (QueuedEmbeddingsPrompt $prompt): bool => $prompt->timeout === 90 && $prompt->count() === 1); }); }); -describe('provider enum support', function () { - test('generate accepts ai provider enum', function () { +describe('provider enum support', function (): void { + test('generate accepts ai provider enum', function (): void { Embeddings::fake(); Embeddings::for(['Enum test'])->generate(provider: Lab::OpenAI); - Embeddings::assertGenerated(function (EmbeddingsPrompt $prompt) { - return in_array('Enum test', $prompt->inputs); - }); + Embeddings::assertGenerated(fn (EmbeddingsPrompt $prompt): bool => in_array('Enum test', $prompt->inputs)); }); - test('queued embeddings accept ai provider enum', function () { + test('queued embeddings accept ai provider enum', function (): void { Embeddings::fake(); Embeddings::for(['Queued enum'])->queue(provider: Lab::Gemini); - Embeddings::assertQueued(fn (QueuedEmbeddingsPrompt $prompt) => $prompt->contains('Queued enum') + Embeddings::assertQueued(fn (QueuedEmbeddingsPrompt $prompt): bool => $prompt->contains('Queued enum') && $prompt->provider === Lab::Gemini); }); }); diff --git a/tests/Feature/EmbeddingsProviderOptionsTest.php b/tests/Feature/EmbeddingsProviderOptionsTest.php index 38207c6e4..8f45304ca 100644 --- a/tests/Feature/EmbeddingsProviderOptionsTest.php +++ b/tests/Feature/EmbeddingsProviderOptionsTest.php @@ -8,7 +8,7 @@ use Laravel\Ai\Prompts\QueuedEmbeddingsPrompt; use Laravel\Ai\Providers\Provider; -beforeEach(function () { +beforeEach(function (): void { config([ 'ai.providers.cohere' => [...config('ai.providers.cohere'), 'key' => 'test-key'], 'ai.providers.openai' => [...config('ai.providers.openai'), 'key' => 'test-key'], @@ -16,7 +16,7 @@ ]); }); -test('cache key differs by provider options so distinct option sets do not collide', function () { +test('cache key differs by provider options so distinct option sets do not collide', function (): void { Http::fake([ 'api.cohere.com/*' => Http::response([ 'embeddings' => ['float' => [[0.1, 0.2, 0.3]]], @@ -35,13 +35,13 @@ ->generate(provider: 'cohere', model: 'embed-v4.0'); $observed = collect(Http::recorded()) - ->map(fn (array $pair) => json_decode($pair[0]->body(), true)['input_type'] ?? null) + ->map(fn (array $pair): mixed => json_decode((string) $pair[0]->body(), true)['input_type'] ?? null) ->all(); expect($observed)->toBe(['search_document', 'search_query']); }); -test('cache key is stable for the same provider options', function () { +test('cache key is stable for the same provider options', function (): void { Http::fake([ 'api.cohere.com/*' => Http::response([ 'embeddings' => ['float' => [[0.1, 0.2, 0.3]]], @@ -62,7 +62,7 @@ expect(Http::recorded())->toHaveCount(1); }); -test('cache key is insensitive to provider option key order', function () { +test('cache key is insensitive to provider option key order', function (): void { Http::fake([ 'api.cohere.com/*' => Http::response([ 'embeddings' => ['float' => [[0.1, 0.2, 0.3]]], @@ -83,7 +83,7 @@ expect(Http::recorded())->toHaveCount(1); }); -test('closure resolver receives the resolved provider and applies per-provider options', function () { +test('closure resolver receives the resolved provider and applies per-provider options', function (): void { Http::fake([ 'api.cohere.com/*' => Http::response([ 'embeddings' => ['float' => [[0.1]]], @@ -99,7 +99,7 @@ $seen = []; Embeddings::for(['Hello']) - ->withProviderOptions(function (Provider $provider) use (&$seen) { + ->withProviderOptions(function (Provider $provider) use (&$seen): array { $seen[] = $provider->driver(); return $provider->driver() === 'cohere' @@ -110,26 +110,26 @@ expect($seen)->toBe(['cohere']); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ($body['input_type'] ?? null) === 'search_query'; }); }); -test('closure provider options are not recorded on the queued prompt fake', function () { +test('closure provider options are not recorded on the queued prompt fake', function (): void { Embeddings::fake(); Embeddings::for(['Hello']) - ->withProviderOptions(fn (Provider $provider) => ['input_type' => 'search_query']) + ->withProviderOptions(fn (Provider $provider): array => ['input_type' => 'search_query']) ->queue(provider: 'cohere', model: 'embed-v4.0'); Embeddings::assertQueued( - fn (QueuedEmbeddingsPrompt $prompt) => $prompt->providerOptions === [], + fn (QueuedEmbeddingsPrompt $prompt): bool => $prompt->providerOptions === [], ); }); -test('closure provider options survive queue serialization round-trip', function () { +test('closure provider options survive queue serialization round-trip', function (): void { Http::fake([ 'api.cohere.com/*' => Http::response([ 'embeddings' => ['float' => [[0.1]]], @@ -138,7 +138,7 @@ ]); $pending = Embeddings::for(['Hello']) - ->withProviderOptions(fn (Provider $provider) => ['input_type' => 'search_query']); + ->withProviderOptions(fn (Provider $provider): array => ['input_type' => 'search_query']); $job = new GenerateEmbeddings($pending, 'cohere', 'embed-v4.0'); @@ -148,14 +148,14 @@ $restored->handle(); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ($body['input_type'] ?? null) === 'search_query'; }); }); -test('closure resolver returning null is treated as no options', function () { +test('closure resolver returning null is treated as no options', function (): void { Http::fake([ 'api.cohere.com/*' => Http::response([ 'embeddings' => ['float' => [[0.1]]], @@ -164,10 +164,10 @@ ]); Embeddings::for(['Hello']) - ->withProviderOptions(fn () => null) + ->withProviderOptions(fn (): null => null) ->generate(provider: 'cohere', model: 'embed-v4.0'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ($body['input_type'] ?? null) === 'search_document'; diff --git a/tests/Feature/FakeJsonSchemaDataTest.php b/tests/Feature/FakeJsonSchemaDataTest.php index c1bf0dfb0..344c150bd 100644 --- a/tests/Feature/FakeJsonSchemaDataTest.php +++ b/tests/Feature/FakeJsonSchemaDataTest.php @@ -5,7 +5,7 @@ use function Laravel\Ai\generate_fake_data_for_json_schema_type; -test('structured data can be faked', function () { +test('structured data can be faked', function (): void { $schema = new JsonSchemaTypeFactory; $response = generate_fake_data_for_json_schema_type((new ObjectType([ diff --git a/tests/Feature/FileFakeTest.php b/tests/Feature/FileFakeTest.php index c7f94b631..1a1066775 100644 --- a/tests/Feature/FileFakeTest.php +++ b/tests/Feature/FileFakeTest.php @@ -7,10 +7,10 @@ use Laravel\Ai\Files\Document; use Laravel\Ai\Responses\FileResponse; -test('files can be faked', function () { +test('files can be faked', function (): void { Files::fake([ 'first-content', - fn ($fileId) => "content-for-{$fileId}", + fn ($fileId): string => "content-for-{$fileId}", new FileResponse('id', mimeType: 'application/json', content: 'third-content'), ]); @@ -27,7 +27,7 @@ ->and($response->content)->toEqual('third-content'); }); -test('files can be faked with no predefined responses', function () { +test('files can be faked with no predefined responses', function (): void { Files::fake(); $response = Files::get('file_1'); @@ -39,8 +39,8 @@ ->and($response->content)->toEqual('fake-content'); }); -test('files can be faked with a closure', function () { - Files::fake(fn ($fileId) => "content-for-{$fileId}"); +test('files can be faked with a closure', function (): void { + Files::fake(fn ($fileId): string => "content-for-{$fileId}"); $response = Files::get('file_1'); expect($response->id)->toEqual('file_1') @@ -51,13 +51,13 @@ ->and($response->content)->toEqual('content-for-file_2'); }); -test('files can prevent stray operations', function () { +test('files can prevent stray operations', function (): void { Files::fake()->preventStrayOperations(); Files::get('file_1'); })->throws(RuntimeException::class); -test('can assert file was stored', function () { +test('can assert file was stored', function (): void { Files::fake(); $id = Document::fromString('Hello, World!', 'text/plain')->as('document.txt')->put()->id; @@ -66,25 +66,25 @@ Document::fromPath(__DIR__.'/../Fixtures/document.txt')->put(); Document::fromUpload(new UploadedFile(__DIR__.'/../Fixtures/report.txt', 'report.txt'))->put(); - Files::assertStored(fn (StorableFile $file) => (string) $file === 'Hello, World!'); + Files::assertStored(fn (StorableFile $file): bool => (string) $file === 'Hello, World!'); - Files::assertStored(fn (StorableFile $file) => trim((string) $file) === 'I am a local document.'); - Files::assertStored(fn (StorableFile $file) => $file->name() === 'document.txt'); + Files::assertStored(fn (StorableFile $file): bool => trim((string) $file) === 'I am a local document.'); + Files::assertStored(fn (StorableFile $file): bool => $file->name() === 'document.txt'); - Files::assertStored(fn (StorableFile $file) => trim((string) $file) === 'I am an expense report.'); - Files::assertStored(fn (StorableFile $file) => $file->name() === 'report.txt'); + Files::assertStored(fn (StorableFile $file): bool => trim((string) $file) === 'I am an expense report.'); + Files::assertStored(fn (StorableFile $file): bool => $file->name() === 'report.txt'); - Files::assertStored(fn (StorableFile $file) => $file->mimeType() === 'text/plain'); - Files::assertNotStored(fn (StorableFile $file) => $file->mimeType() === 'application/json'); + Files::assertStored(fn (StorableFile $file): bool => $file->mimeType() === 'text/plain'); + Files::assertNotStored(fn (StorableFile $file): bool => $file->mimeType() === 'application/json'); }); -test('can assert no files were stored', function () { +test('can assert no files were stored', function (): void { Files::fake(); Files::assertNothingStored(); }); -test('can override the filename when storing files from each document constructor', function () { +test('can override the filename when storing files from each document constructor', function (): void { Files::fake(); Document::fromString('Hello, World!', 'text/plain')->put(name: 'custom-name.txt'); @@ -92,28 +92,28 @@ Document::fromUpload(new UploadedFile(__DIR__.'/../Fixtures/report.txt', 'report.txt')) ->put(name: 'renamed-report.txt'); - Files::assertStored(fn (StorableFile $file) => $file->name() === 'custom-name.txt'); - Files::assertStored(fn (StorableFile $file) => $file->name() === 'renamed-document.txt'); - Files::assertStored(fn (StorableFile $file) => $file->name() === 'renamed-report.txt'); + Files::assertStored(fn (StorableFile $file): bool => $file->name() === 'custom-name.txt'); + Files::assertStored(fn (StorableFile $file): bool => $file->name() === 'renamed-document.txt'); + Files::assertStored(fn (StorableFile $file): bool => $file->name() === 'renamed-report.txt'); }); -test('can override the filename when storing an existing storable file', function () { +test('can override the filename when storing an existing storable file', function (): void { Files::fake(); Files::put(Document::fromPath(__DIR__.'/../Fixtures/document.txt'), name: 'override.txt'); - Files::assertStored(fn (StorableFile $file) => $file->name() === 'override.txt'); + Files::assertStored(fn (StorableFile $file): bool => $file->name() === 'override.txt'); }); -test('can override the filename when storing files from a local path', function () { +test('can override the filename when storing files from a local path', function (): void { Files::fake(); Files::putFromPath(__DIR__.'/../Fixtures/document.txt', name: 'from-path.txt'); - Files::assertStored(fn (StorableFile $file) => $file->name() === 'from-path.txt'); + Files::assertStored(fn (StorableFile $file): bool => $file->name() === 'from-path.txt'); }); -test('can override the filename when storing files from a storage disk', function () { +test('can override the filename when storing files from a storage disk', function (): void { Files::fake(); Storage::fake('docs'); @@ -121,10 +121,10 @@ Files::putFromStorage('original.txt', disk: 'docs', name: 'from-storage.txt'); - Files::assertStored(fn (StorableFile $file) => $file->name() === 'from-storage.txt'); + Files::assertStored(fn (StorableFile $file): bool => $file->name() === 'from-storage.txt'); }); -test('can override the mime type when storing files from each source', function () { +test('can override the mime type when storing files from each source', function (): void { Files::fake(); Storage::fake('docs'); @@ -134,34 +134,34 @@ Files::putFromPath(__DIR__.'/../Fixtures/document.txt', mimeType: 'application/x-path'); Files::put(Document::fromStorage('original.txt', 'docs'), mimeType: 'application/x-storage'); - Files::assertStored(fn (StorableFile $file) => $file->mimeType() === 'application/x-local'); - Files::assertStored(fn (StorableFile $file) => $file->mimeType() === 'application/x-path'); - Files::assertStored(fn (StorableFile $file) => $file->mimeType() === 'application/x-storage'); - Files::assertNotStored(fn (StorableFile $file) => $file->mimeType() === 'text/plain'); + Files::assertStored(fn (StorableFile $file): bool => $file->mimeType() === 'application/x-local'); + Files::assertStored(fn (StorableFile $file): bool => $file->mimeType() === 'application/x-path'); + Files::assertStored(fn (StorableFile $file): bool => $file->mimeType() === 'application/x-storage'); + Files::assertNotStored(fn (StorableFile $file): bool => $file->mimeType() === 'text/plain'); }); -test('can override the name and mime type when storing an existing storable file', function () { +test('can override the name and mime type when storing an existing storable file', function (): void { Files::fake(); Files::put(Document::fromPath(__DIR__.'/../Fixtures/document.txt'), mimeType: 'application/json', name: 'renamed.txt'); - Files::assertStored(fn (StorableFile $file) => $file->name() === 'renamed.txt'); - Files::assertStored(fn (StorableFile $file) => $file->mimeType() === 'application/json'); - Files::assertNotStored(fn (StorableFile $file) => $file->mimeType() === 'text/plain'); + Files::assertStored(fn (StorableFile $file): bool => $file->name() === 'renamed.txt'); + Files::assertStored(fn (StorableFile $file): bool => $file->mimeType() === 'application/json'); + Files::assertNotStored(fn (StorableFile $file): bool => $file->mimeType() === 'text/plain'); }); -test('can assert file was deleted', function () { +test('can assert file was deleted', function (): void { Files::fake(); Files::delete('file_123'); Files::assertDeleted('file_123'); - Files::assertDeleted(fn ($id) => $id === 'file_123'); + Files::assertDeleted(fn ($id): bool => $id === 'file_123'); Files::assertNotDeleted('file_456'); - Files::assertNotDeleted(fn ($id) => $id === 'file_456'); + Files::assertNotDeleted(fn ($id): bool => $id === 'file_456'); }); -test('can assert no files were deleted', function () { +test('can assert no files were deleted', function (): void { Files::fake(); Files::assertNothingDeleted(); diff --git a/tests/Feature/FileFromArrayTest.php b/tests/Feature/FileFromArrayTest.php index 71962a262..3ca2991bd 100644 --- a/tests/Feature/FileFromArrayTest.php +++ b/tests/Feature/FileFromArrayTest.php @@ -17,20 +17,20 @@ use Laravel\Ai\Files\StoredImage; dataset('attachment types', [ - 'base64-image' => [fn () => (new Base64Image('aGVsbG8=', 'image/png'))->as('hi.png'), Base64Image::class, ['base64' => 'aGVsbG8=', 'mime' => 'image/png']], - 'local-image' => [fn () => (new LocalImage('/tmp/a.png', 'image/png'))->as('a.png'), LocalImage::class, ['path' => '/tmp/a.png', 'mime' => 'image/png']], - 'stored-image' => [fn () => (new StoredImage('photos/a.png', 'local'))->as('a.png'), StoredImage::class, ['path' => 'photos/a.png', 'disk' => 'local']], - 'remote-image' => [fn () => (new RemoteImage('https://x/y.png', 'image/png'))->as('y.png'), RemoteImage::class, ['url' => 'https://x/y.png', 'mime' => 'image/png']], - 'provider-image' => [fn () => (new ProviderImage('file_img_1'))->as('img.png'), ProviderImage::class, ['id' => 'file_img_1']], - 'base64-document' => [fn () => (new Base64Document('aGVsbG8=', 'application/pdf'))->as('a.pdf'), Base64Document::class, ['base64' => 'aGVsbG8=', 'mime' => 'application/pdf']], - 'local-document' => [fn () => (new LocalDocument('/tmp/a.pdf', 'application/pdf'))->as('a.pdf'), LocalDocument::class, ['path' => '/tmp/a.pdf', 'mime' => 'application/pdf']], - 'stored-document' => [fn () => (new StoredDocument('docs/a.pdf', 'local'))->as('a.pdf'), StoredDocument::class, ['path' => 'docs/a.pdf', 'disk' => 'local']], - 'remote-document' => [fn () => (new RemoteDocument('https://x/y.pdf', 'application/pdf'))->as('y.pdf'), RemoteDocument::class, ['url' => 'https://x/y.pdf', 'mime' => 'application/pdf']], - 'provider-document' => [fn () => (new ProviderDocument('file_doc_1'))->as('doc.pdf'), ProviderDocument::class, ['id' => 'file_doc_1']], - 'base64-audio' => [fn () => (new Base64Audio('aGVsbG8=', 'audio/mpeg'))->as('a.mp3'), Base64Audio::class, ['base64' => 'aGVsbG8=', 'mime' => 'audio/mpeg']], - 'local-audio' => [fn () => (new LocalAudio('/tmp/a.mp3', 'audio/mpeg'))->as('a.mp3'), LocalAudio::class, ['path' => '/tmp/a.mp3', 'mime' => 'audio/mpeg']], - 'stored-audio' => [fn () => (new StoredAudio('clips/a.mp3', 'local'))->as('a.mp3'), StoredAudio::class, ['path' => 'clips/a.mp3', 'disk' => 'local']], - 'remote-audio' => [fn () => (new RemoteAudio('https://x/y.mp3', 'audio/mpeg'))->as('y.mp3'), RemoteAudio::class, ['url' => 'https://x/y.mp3', 'mime' => 'audio/mpeg']], + 'base64-image' => [fn (): Base64Image => (new Base64Image('aGVsbG8=', 'image/png'))->as('hi.png'), Base64Image::class, ['base64' => 'aGVsbG8=', 'mime' => 'image/png']], + 'local-image' => [fn (): LocalImage => (new LocalImage('/tmp/a.png', 'image/png'))->as('a.png'), LocalImage::class, ['path' => '/tmp/a.png', 'mime' => 'image/png']], + 'stored-image' => [fn (): StoredImage => (new StoredImage('photos/a.png', 'local'))->as('a.png'), StoredImage::class, ['path' => 'photos/a.png', 'disk' => 'local']], + 'remote-image' => [fn (): RemoteImage => (new RemoteImage('https://x/y.png', 'image/png'))->as('y.png'), RemoteImage::class, ['url' => 'https://x/y.png', 'mime' => 'image/png']], + 'provider-image' => [fn (): ProviderImage => (new ProviderImage('file_img_1'))->as('img.png'), ProviderImage::class, ['id' => 'file_img_1']], + 'base64-document' => [fn (): Base64Document => (new Base64Document('aGVsbG8=', 'application/pdf'))->as('a.pdf'), Base64Document::class, ['base64' => 'aGVsbG8=', 'mime' => 'application/pdf']], + 'local-document' => [fn (): LocalDocument => (new LocalDocument('/tmp/a.pdf', 'application/pdf'))->as('a.pdf'), LocalDocument::class, ['path' => '/tmp/a.pdf', 'mime' => 'application/pdf']], + 'stored-document' => [fn (): StoredDocument => (new StoredDocument('docs/a.pdf', 'local'))->as('a.pdf'), StoredDocument::class, ['path' => 'docs/a.pdf', 'disk' => 'local']], + 'remote-document' => [fn (): RemoteDocument => (new RemoteDocument('https://x/y.pdf', 'application/pdf'))->as('y.pdf'), RemoteDocument::class, ['url' => 'https://x/y.pdf', 'mime' => 'application/pdf']], + 'provider-document' => [fn (): ProviderDocument => (new ProviderDocument('file_doc_1'))->as('doc.pdf'), ProviderDocument::class, ['id' => 'file_doc_1']], + 'base64-audio' => [fn (): Base64Audio => (new Base64Audio('aGVsbG8=', 'audio/mpeg'))->as('a.mp3'), Base64Audio::class, ['base64' => 'aGVsbG8=', 'mime' => 'audio/mpeg']], + 'local-audio' => [fn (): LocalAudio => (new LocalAudio('/tmp/a.mp3', 'audio/mpeg'))->as('a.mp3'), LocalAudio::class, ['path' => '/tmp/a.mp3', 'mime' => 'audio/mpeg']], + 'stored-audio' => [fn (): StoredAudio => (new StoredAudio('clips/a.mp3', 'local'))->as('a.mp3'), StoredAudio::class, ['path' => 'clips/a.mp3', 'disk' => 'local']], + 'remote-audio' => [fn (): RemoteAudio => (new RemoteAudio('https://x/y.mp3', 'audio/mpeg'))->as('y.mp3'), RemoteAudio::class, ['url' => 'https://x/y.mp3', 'mime' => 'audio/mpeg']], ]); dataset('malformed attachment types', [ @@ -50,7 +50,7 @@ 'remote-audio' => [['type' => 'remote-audio'], 'url'], ]); -test('File::fromArray round-trips attachment types', function (Closure $factory, string $class, array $properties) { +test('File::fromArray round-trips attachment types', function (Closure $factory, string $class, array $properties): void { $original = $factory(); $rehydrated = File::fromArray($original->toArray()); @@ -63,17 +63,17 @@ } })->with('attachment types'); -test('File::fromArray returns null for an unknown type', function () { +test('File::fromArray returns null for an unknown type', function (): void { expect(File::fromArray(['type' => 'video']))->toBeNull() ->and(File::fromArray([]))->toBeNull(); }); -test('File::fromArray throws for malformed known attachment types', function (array $data, string $field) { - expect(fn () => File::fromArray($data)) +test('File::fromArray throws for malformed known attachment types', function (array $data, string $field): void { + expect(fn (): ?\Laravel\Ai\Files\File => File::fromArray($data)) ->toThrow(InvalidArgumentException::class, "[{$field}] is missing or invalid"); })->with('malformed attachment types'); -test('File::fromArray restores the name even when toArray emits null', function () { +test('File::fromArray restores the name even when toArray emits null', function (): void { $rehydrated = File::fromArray([ 'type' => 'remote-image', 'url' => 'https://example.com/x.png', diff --git a/tests/Feature/FileProviderOptionsTest.php b/tests/Feature/FileProviderOptionsTest.php index 3a509be8f..d9ede4370 100644 --- a/tests/Feature/FileProviderOptionsTest.php +++ b/tests/Feature/FileProviderOptionsTest.php @@ -3,14 +3,14 @@ use Laravel\Ai\Enums\Lab; use Laravel\Ai\Files\Document; -test('array provider options resolve for the given provider', function () { +test('array provider options resolve for the given provider', function (): void { $document = Document::fromString('Hello')->withProviderOptions(['purpose' => 'fine-tune']); expect($document->providerOptions(Lab::OpenAI))->toBe(['purpose' => 'fine-tune']); }); -test('closure provider options resolve per provider', function () { - $document = Document::fromString('Hello')->withProviderOptions(fn (Lab $provider) => match ($provider) { +test('closure provider options resolve per provider', function (): void { + $document = Document::fromString('Hello')->withProviderOptions(fn (Lab $provider): array => match ($provider) { Lab::OpenAI => ['purpose' => 'assistants'], default => [], }); @@ -19,8 +19,8 @@ ->and($document->providerOptions(Lab::Anthropic))->toBe([]); }); -test('closure provider options survive php serialization', function () { - $document = Document::fromString('Hello')->withProviderOptions(fn (Lab $provider) => match ($provider) { +test('closure provider options survive php serialization', function (): void { + $document = Document::fromString('Hello')->withProviderOptions(fn (Lab $provider): array => match ($provider) { Lab::OpenAI => ['purpose' => 'assistants'], default => [], }); diff --git a/tests/Feature/FileToArrayTest.php b/tests/Feature/FileToArrayTest.php index f0a17f9fd..2fa56d499 100644 --- a/tests/Feature/FileToArrayTest.php +++ b/tests/Feature/FileToArrayTest.php @@ -6,7 +6,7 @@ use Laravel\Ai\Files\Document; use Laravel\Ai\Files\Image; -test('stored document toArray falls back to basename without touching the disk', function () { +test('stored document toArray falls back to basename without touching the disk', function (): void { Storage::fake('docs'); $array = Document::fromStorage('invoices/invoice-2026-04.pdf', 'docs')->toArray(); @@ -19,7 +19,7 @@ ]); }); -test('stored audio toArray falls back to basename', function () { +test('stored audio toArray falls back to basename', function (): void { Storage::fake('docs'); $array = Audio::fromStorage('clips/hello.mp3', 'docs')->toArray(); @@ -29,7 +29,7 @@ expect($array)->not->toHaveKey('mime'); }); -test('stored image toArray falls back to basename', function () { +test('stored image toArray falls back to basename', function (): void { Storage::fake('docs'); $array = Image::fromStorage('photos/avatar.png', 'docs')->toArray(); @@ -39,7 +39,7 @@ expect($array)->not->toHaveKey('mime'); }); -test('local image toArray uses basename and the raw mime property', function () { +test('local image toArray uses basename and the raw mime property', function (): void { $path = tempnam(sys_get_temp_dir(), 'local-image'); file_put_contents($path, 'data'); @@ -55,7 +55,7 @@ } }); -test('local image toArray returns the explicitly set mime type', function () { +test('local image toArray returns the explicitly set mime type', function (): void { $path = tempnam(sys_get_temp_dir(), 'local-image'); file_put_contents($path, 'data'); @@ -68,7 +68,7 @@ } }); -test('base64 document toArray reflects name and mime', function () { +test('base64 document toArray reflects name and mime', function (): void { $doc = Document::fromString('hello world', 'text/plain')->as('greeting.txt'); expect($doc->toArray())->toMatchArray([ @@ -78,7 +78,7 @@ ]); }); -test('remote document toArray never issues an HTTP request', function () { +test('remote document toArray never issues an HTTP request', function (): void { Http::preventStrayRequests(); Http::fake(); @@ -94,7 +94,7 @@ Http::assertNothingSent(); }); -test('remote image toArray returns the explicitly set mime type without HTTP calls', function () { +test('remote image toArray returns the explicitly set mime type without HTTP calls', function (): void { Http::preventStrayRequests(); Http::fake(); @@ -106,7 +106,7 @@ Http::assertNothingSent(); }); -test('remote document toArray handles urls without a path component', function () { +test('remote document toArray handles urls without a path component', function (): void { Http::preventStrayRequests(); Http::fake(); @@ -126,7 +126,7 @@ Http::assertNothingSent(); }); -test('local image toArray does not touch the filesystem', function () { +test('local image toArray does not touch the filesystem', function (): void { $path = '/tmp/this-file-definitely-does-not-exist-'.uniqid().'.png'; $array = Image::fromPath($path)->toArray(); diff --git a/tests/Feature/FilesystemToolsTest.php b/tests/Feature/FilesystemToolsTest.php index 04c542e0b..52fbd0193 100644 --- a/tests/Feature/FilesystemToolsTest.php +++ b/tests/Feature/FilesystemToolsTest.php @@ -24,11 +24,11 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { Storage::fake('local'); }); -test('list files returns files and directories under a path', function () { +test('list files returns files and directories under a path', function (): void { Storage::disk('local')->put('docs/a.txt', 'A'); Storage::disk('local')->put('docs/nested/b.txt', 'B'); Storage::disk('local')->put('root.txt', 'R'); @@ -40,7 +40,7 @@ ->not->toContain('root.txt'); }); -test('list files can recurse into subdirectories', function () { +test('list files can recurse into subdirectories', function (): void { Storage::disk('local')->put('docs/a.txt', 'A'); Storage::disk('local')->put('docs/nested/b.txt', 'B'); @@ -49,7 +49,7 @@ expect($result)->toContain('docs/a.txt')->toContain('docs/nested/b.txt'); }); -test('read file returns text contents', function () { +test('read file returns text contents', function (): void { Storage::disk('local')->put('note.txt', 'Hello world'); $result = (new ReadFile('local'))->handle(new Request(['path' => 'note.txt'])); @@ -57,13 +57,13 @@ expect($result)->toBe('Hello world'); }); -test('read file reports a missing file', function () { +test('read file reports a missing file', function (): void { $result = (new ReadFile('local'))->handle(new Request(['path' => 'missing.txt'])); expect($result)->toBe('File [missing.txt] does not exist.'); }); -test('read file rejects an oversized file', function () { +test('read file rejects an oversized file', function (): void { Storage::disk('local')->put('big.txt', str_repeat('a', 300 * 1024)); $result = (new ReadFile('local'))->handle(new Request(['path' => 'big.txt'])); @@ -71,7 +71,7 @@ expect($result)->toContain('too large to read inline'); }); -test('read file rejects a binary file', function () { +test('read file rejects a binary file', function (): void { Storage::disk('local')->put('image.bin', "\xff\xfe\x00\x01binary"); $result = (new ReadFile('local'))->handle(new Request(['path' => 'image.bin'])); @@ -79,7 +79,7 @@ expect($result)->toContain('appears to be binary'); }); -test('file exists reports presence and absence', function () { +test('file exists reports presence and absence', function (): void { Storage::disk('local')->put('there.txt', 'x'); expect((new FileExists('local'))->handle(new Request(['path' => 'there.txt']))) @@ -89,7 +89,7 @@ ->toBe('File [nope.txt] does not exist.'); }); -test('file exists does not report directories as files', function () { +test('file exists does not report directories as files', function (): void { Storage::disk('local')->makeDirectory('docs'); $result = (new FileExists('local'))->handle(new Request(['path' => 'docs'])); @@ -97,7 +97,7 @@ expect($result)->toBe('File [docs] does not exist.'); }); -test('file metadata returns size and mime type', function () { +test('file metadata returns size and mime type', function (): void { Storage::disk('local')->put('data.txt', 'twelve bytes'); $metadata = json_decode((new GetFileMetadata('local'))->handle(new Request(['path' => 'data.txt'])), true); @@ -106,13 +106,13 @@ ->and($metadata)->toHaveKeys(['mime_type', 'last_modified', 'visibility']); }); -test('file metadata reports a missing file', function () { +test('file metadata reports a missing file', function (): void { $result = (new GetFileMetadata('local'))->handle(new Request(['path' => 'missing.txt'])); expect($result)->toBe('File [missing.txt] does not exist.'); }); -test('file url returns a usable string and never throws', function () { +test('file url returns a usable string and never throws', function (): void { Storage::disk('local')->put('pic.txt', 'x'); expect((new GetFileUrl('local'))->handle(new Request(['path' => 'pic.txt'])))->toContain('pic.txt'); @@ -120,13 +120,13 @@ expect((new GetFileUrl('local'))->handle(new Request(['path' => 'pic.txt', 'expires_in_minutes' => 5])))->toContain('pic.txt'); }); -test('file url reports a missing file', function () { +test('file url reports a missing file', function (): void { $result = (new GetFileUrl('local'))->handle(new Request(['path' => 'missing.txt'])); expect($result)->toBe('File [missing.txt] does not exist.'); }); -test('file url does not generate urls for directories', function () { +test('file url does not generate urls for directories', function (): void { Storage::disk('local')->makeDirectory('docs'); $result = (new GetFileUrl('local'))->handle(new Request(['path' => 'docs'])); @@ -134,7 +134,7 @@ expect($result)->toBe('File [docs] does not exist.'); }); -test('write file creates a file', function () { +test('write file creates a file', function (): void { $result = (new WriteFile('local'))->handle(new Request(['path' => 'out.txt', 'contents' => 'written'])); expect($result)->toContain('Wrote'); @@ -142,7 +142,7 @@ expect(Storage::disk('local')->get('out.txt'))->toBe('written'); }); -test('write file reports write failures', function () { +test('write file reports write failures', function (): void { $disk = Mockery::mock(Filesystem::class); $disk->shouldReceive('put')->once()->with('out.txt', 'written')->andReturnFalse(); @@ -151,7 +151,7 @@ expect($result)->toBe('Unable to write [out.txt].'); }); -test('delete file removes a file', function () { +test('delete file removes a file', function (): void { Storage::disk('local')->put('gone.txt', 'x'); $result = (new DeleteFile('local'))->handle(new Request(['path' => 'gone.txt'])); @@ -160,13 +160,13 @@ Storage::disk('local')->assertMissing('gone.txt'); }); -test('delete file reports a missing file', function () { +test('delete file reports a missing file', function (): void { $result = (new DeleteFile('local'))->handle(new Request(['path' => 'missing.txt'])); expect($result)->toBe('File [missing.txt] does not exist.'); }); -test('delete file does not report directories as files', function () { +test('delete file does not report directories as files', function (): void { Storage::disk('local')->makeDirectory('docs'); $result = (new DeleteFile('local'))->handle(new Request(['path' => 'docs'])); @@ -175,7 +175,7 @@ Storage::disk('local')->assertExists('docs'); }); -test('delete file reports delete failures', function () { +test('delete file reports delete failures', function (): void { $disk = Mockery::mock(Filesystem::class); $disk->shouldReceive('size')->once()->with('gone.txt')->andReturn(1); $disk->shouldReceive('delete')->once()->with('gone.txt')->andReturnFalse(); @@ -185,7 +185,7 @@ expect($result)->toBe('Unable to delete [gone.txt].'); }); -test('copy file duplicates a file', function () { +test('copy file duplicates a file', function (): void { Storage::disk('local')->put('src.txt', 'data'); $result = (new CopyFile('local'))->handle(new Request(['from' => 'src.txt', 'to' => 'dst.txt'])); @@ -194,52 +194,52 @@ Storage::disk('local')->assertExists('dst.txt'); }); -test('copy file reports a missing source', function () { +test('copy file reports a missing source', function (): void { $result = (new CopyFile('local'))->handle(new Request(['from' => 'missing.txt', 'to' => 'dst.txt'])); expect($result)->toBe('Unable to copy [missing.txt] to [dst.txt]. The source file may not exist.'); }); -test('file storage tools all returns every tool as a collection', function () { +test('file storage tools all returns every tool as a collection', function (): void { $tools = FileStorage::all('local'); expect($tools)->toBeInstanceOf(Collection::class) ->toHaveCount(8) - ->and($tools->contains(fn ($tool) => $tool instanceof WriteFile))->toBeTrue(); + ->and($tools->contains(fn ($tool): bool => $tool instanceof WriteFile))->toBeTrue(); }); -test('file storage tools can be filtered as a collection', function () { +test('file storage tools can be filtered as a collection', function (): void { $tools = FileStorage::all('local') - ->reject(fn ($tool) => $tool instanceof DeleteFile); + ->reject(fn ($tool): bool => $tool instanceof DeleteFile); expect($tools)->toHaveCount(7) - ->and($tools->contains(fn ($tool) => $tool instanceof DeleteFile))->toBeFalse(); + ->and($tools->contains(fn ($tool): bool => $tool instanceof DeleteFile))->toBeFalse(); }); -test('file storage tools readOnly returns only read tools', function () { +test('file storage tools readOnly returns only read tools', function (): void { $tools = FileStorage::readOnly('local'); expect($tools)->toBeInstanceOf(Collection::class) ->toHaveCount(5) - ->and($tools->contains(fn ($tool) => $tool instanceof ReadFile))->toBeTrue() - ->and($tools->contains(fn ($tool) => $tool instanceof WriteFile))->toBeFalse() - ->and($tools->contains(fn ($tool) => $tool instanceof DeleteFile))->toBeFalse() - ->and($tools->contains(fn ($tool) => $tool instanceof CopyFile))->toBeFalse(); + ->and($tools->contains(fn ($tool): bool => $tool instanceof ReadFile))->toBeTrue() + ->and($tools->contains(fn ($tool): bool => $tool instanceof WriteFile))->toBeFalse() + ->and($tools->contains(fn ($tool): bool => $tool instanceof DeleteFile))->toBeFalse() + ->and($tools->contains(fn ($tool): bool => $tool instanceof CopyFile))->toBeFalse(); }); -test('filesystem tool names resolve to class basenames', function () { +test('filesystem tool names resolve to class basenames', function (): void { expect(ToolNameResolver::resolve(new ReadFile('local')))->toBe('ReadFile') ->and(ToolNameResolver::resolve(new ListFiles('local')))->toBe('ListFiles') ->and(ToolNameResolver::resolve(new WriteFile('local')))->toBe('WriteFile'); }); -test('filesystem tool schemas build', function () { +test('filesystem tool schemas build', function (): void { $schema = (new CopyFile('local'))->schema(new JsonSchemaTypeFactory); expect($schema)->toHaveKeys(['from', 'to']); }); -test('every filesystem tool maps to a strict-compliant openai schema', function () { +test('every filesystem tool maps to a strict-compliant openai schema', function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', @@ -250,7 +250,7 @@ agent(tools: FileStorage::all('local')) ->prompt('List the files', provider: 'openai'); - Http::assertSent(function (Illuminate\Http\Client\Request $request) { + Http::assertSent(function (Illuminate\Http\Client\Request $request): bool { $tools = collect(data_get(json_decode($request->body(), true), 'tools'))->where('type', 'function'); if ($tools->count() !== 8) { @@ -271,7 +271,7 @@ }); }); -test('agent copies a file end to end', function () { +test('agent copies a file end to end', function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', @@ -294,7 +294,7 @@ Storage::disk('local')->assertCount('wallpapers', 1); }); -test('agent deletes a file end to end', function () { +test('agent deletes a file end to end', function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', diff --git a/tests/Feature/ImageFakeTest.php b/tests/Feature/ImageFakeTest.php index 977fb9114..6552ea595 100644 --- a/tests/Feature/ImageFakeTest.php +++ b/tests/Feature/ImageFakeTest.php @@ -10,22 +10,22 @@ use Laravel\Ai\Responses\Data\Usage; use Laravel\Ai\Responses\ImageResponse; -test('image rejects empty prompt', function () { +test('image rejects empty prompt', function (): void { Image::fake(); Image::of('')->generate(); })->throws(InvalidArgumentException::class, 'A prompt is required to generate an image.'); -test('image rejects whitespace-only prompt', function () { +test('image rejects whitespace-only prompt', function (): void { Image::fake(); Image::of(' ')->generate(); })->throws(InvalidArgumentException::class, 'A prompt is required to generate an image.'); -test('images can be faked', function () { +test('images can be faked', function (): void { Image::fake([ base64_encode('first-image'), - fn (ImagePrompt $prompt) => base64_encode('second-image-'.$prompt->prompt), + fn (ImagePrompt $prompt): string => base64_encode('second-image-'.$prompt->prompt), new ImageResponse( new Collection([new GeneratedImage(base64_encode('third-image'))]), new Usage, @@ -43,21 +43,19 @@ expect($response->firstImage()->image)->toEqual(base64_encode('third-image')); // Assertion tests... - Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->prompt === 'First prompt'); - Image::assertNotGenerated(fn (ImagePrompt $prompt) => $prompt->prompt === 'Missing prompt'); + Image::assertGenerated(fn (ImagePrompt $prompt): bool => $prompt->prompt === 'First prompt'); + Image::assertNotGenerated(fn (ImagePrompt $prompt): bool => $prompt->prompt === 'Missing prompt'); - Image::assertGenerated(function (ImagePrompt $prompt) { - return $prompt->prompt === 'First prompt'; - }); + Image::assertGenerated(fn (ImagePrompt $prompt): bool => $prompt->prompt === 'First prompt'); }); -test('can assert no images were generated', function () { +test('can assert no images were generated', function (): void { Image::fake(); Image::assertNothingGenerated(); }); -test('images can be faked with no predefined responses', function () { +test('images can be faked with no predefined responses', function (): void { Image::fake(); $response = Image::of('First prompt')->generate(); @@ -67,10 +65,8 @@ expect($response->firstImage()->image)->toEqual(base64_encode('fake-image-content')); }); -test('images can be faked with a single closure that is invoked for every generation', function () { - Image::fake(function (ImagePrompt $prompt) { - return base64_encode('image-for-'.$prompt->prompt); - }); +test('images can be faked with a single closure that is invoked for every generation', function (): void { + Image::fake(fn (ImagePrompt $prompt): string => base64_encode('image-for-'.$prompt->prompt)); $response = Image::of('First prompt')->generate(); expect($response->firstImage()->image)->toEqual(base64_encode('image-for-First prompt')); @@ -79,102 +75,90 @@ expect($response->firstImage()->image)->toEqual(base64_encode('image-for-Second prompt')); }); -test('images can prevent stray generations', function () { +test('images can prevent stray generations', function (): void { Image::fake()->preventStrayImages(); Image::of('First prompt')->generate(); })->throws(RuntimeException::class); -test('fake closures can throw exceptions', function () { - Image::fake(function () { +test('fake closures can throw exceptions', function (): void { + Image::fake(function (): void { throw new Exception('Something went wrong'); }); Image::of('Test prompt')->generate(); })->throws(Exception::class); -test('image size and quality are recorded', function () { +test('image size and quality are recorded', function (): void { Image::fake(); Image::of('A sunset')->square()->quality('high')->generate(); - Image::assertGenerated(function (ImagePrompt $prompt) { - return $prompt->prompt === 'A sunset' - && $prompt->size === '1:1' - && $prompt->quality === 'high'; - }); + Image::assertGenerated(fn (ImagePrompt $prompt): bool => $prompt->prompt === 'A sunset' + && $prompt->size === '1:1' + && $prompt->quality === 'high'); }); -test('image portrait aspect ratio is recorded', function () { +test('image portrait aspect ratio is recorded', function (): void { Image::fake(); Image::of('A sunset')->portrait()->generate(); - Image::assertGenerated(function (ImagePrompt $prompt) { - return $prompt->prompt === 'A sunset' - && $prompt->size === '2:3'; - }); + Image::assertGenerated(fn (ImagePrompt $prompt): bool => $prompt->prompt === 'A sunset' + && $prompt->size === '2:3'); }); -test('image custom size is recorded', function () { +test('image custom size is recorded', function (): void { Image::fake(); Image::of('A sunset')->size('16:9')->generate(); - Image::assertGenerated(function (ImagePrompt $prompt) { - return $prompt->prompt === 'A sunset' - && $prompt->size === '16:9'; - }); + Image::assertGenerated(fn (ImagePrompt $prompt): bool => $prompt->prompt === 'A sunset' + && $prompt->size === '16:9'); }); -test('queued images can be faked', function () { +test('queued images can be faked', function (): void { Image::fake(); Image::of('First prompt')->queue(); - Image::assertQueued(fn (QueuedImagePrompt $prompt) => $prompt->prompt === 'First prompt'); - Image::assertNotQueued(fn (QueuedImagePrompt $prompt) => $prompt->contains('Second prompt')); + Image::assertQueued(fn (QueuedImagePrompt $prompt): bool => $prompt->prompt === 'First prompt'); + Image::assertNotQueued(fn (QueuedImagePrompt $prompt): bool => $prompt->contains('Second prompt')); - Image::assertQueued(function (QueuedImagePrompt $prompt) { - return $prompt->prompt === 'First prompt'; - }); + Image::assertQueued(fn (QueuedImagePrompt $prompt): bool => $prompt->prompt === 'First prompt'); - Image::assertNotQueued(function (QueuedImagePrompt $prompt) { - return $prompt->prompt === 'Second prompt'; - }); + Image::assertNotQueued(fn (QueuedImagePrompt $prompt): bool => $prompt->prompt === 'Second prompt'); }); -test('can assert no images were queued', function () { +test('can assert no images were queued', function (): void { Image::fake(); Image::assertNothingQueued(); }); -test('generate accepts ai provider enum', function () { +test('generate accepts ai provider enum', function (): void { Image::fake(); Image::of('Enum image')->generate(provider: Lab::Gemini); - Image::assertGenerated(fn (ImagePrompt $prompt) => $prompt->prompt === 'Enum image'); + Image::assertGenerated(fn (ImagePrompt $prompt): bool => $prompt->prompt === 'Enum image'); }); -test('queued image accepts ai provider enum', function () { +test('queued image accepts ai provider enum', function (): void { Image::fake(); Image::of('Queued enum image')->queue(provider: Lab::OpenAI); - Image::assertQueued(fn (QueuedImagePrompt $prompt) => $prompt->prompt === 'Queued enum image' + Image::assertQueued(fn (QueuedImagePrompt $prompt): bool => $prompt->prompt === 'Queued enum image' && $prompt->provider === Lab::OpenAI); }); -test('queued image size and quality are recorded', function () { +test('queued image size and quality are recorded', function (): void { Image::fake(); Image::of('A sunset')->landscape()->quality('low')->queue(); - Image::assertQueued(function (QueuedImagePrompt $prompt) { - return $prompt->prompt === 'A sunset' - && $prompt->size === '3:2' - && $prompt->quality === 'low'; - }); + Image::assertQueued(fn (QueuedImagePrompt $prompt): bool => $prompt->prompt === 'A sunset' + && $prompt->size === '3:2' + && $prompt->quality === 'low'); }); diff --git a/tests/Feature/McpServerToolTest.php b/tests/Feature/McpServerToolTest.php index ab2ffdd5a..c70957c61 100644 --- a/tests/Feature/McpServerToolTest.php +++ b/tests/Feature/McpServerToolTest.php @@ -6,7 +6,7 @@ use Laravel\Ai\Responses\Data\ToolCall; use Tests\Fixtures\Mcp\FakeMcpServerTool; -test('agents can return mcp server tools directly', function () { +test('agents can return mcp server tools directly', function (): void { $serverTool = new FakeMcpServerTool; $agent = new class($serverTool) implements Agent, HasTools diff --git a/tests/Feature/McpToolTest.php b/tests/Feature/McpToolTest.php index 986ad76f5..0ed4beaf9 100644 --- a/tests/Feature/McpToolTest.php +++ b/tests/Feature/McpToolTest.php @@ -9,7 +9,7 @@ use Tests\Fixtures\Mcp\FakeMcpTool; use Tests\Fixtures\Mcp\FakeMcpToolResult; -test('agents can return mcp client tools directly', function () { +test('agents can return mcp client tools directly', function (): void { $client = new FakeMcpClient; $mcpTool = new FakeMcpTool($client, 'search', null, 'Search records.', [ 'type' => 'object', @@ -63,7 +63,7 @@ public function tools(): iterable ); }); -test('it runs mcp client tools whose schema uses unrepresentable json schema', function () { +test('it runs mcp client tools whose schema uses unrepresentable json schema', function (): void { $client = new FakeMcpClient; $union = new McpTool(new FakeMcpTool($client, 'set_value', null, 'Set a value.', [ diff --git a/tests/Feature/Providers/Anthropic/AgentFakeTest.php b/tests/Feature/Providers/Anthropic/AgentFakeTest.php index 4231732ae..1f44e2be7 100644 --- a/tests/Feature/Providers/Anthropic/AgentFakeTest.php +++ b/tests/Feature/Providers/Anthropic/AgentFakeTest.php @@ -2,7 +2,7 @@ use Tests\Fixtures\Agents\AnthropicAgent; -test('anthropic agent can be faked', function () { +test('anthropic agent can be faked', function (): void { AnthropicAgent::fake(['Test response']); $response = (new AnthropicAgent)->prompt('Hello'); @@ -10,15 +10,15 @@ expect($response->text)->toBe('Test response'); }); -test('anthropic agent fake with closure', function () { - AnthropicAgent::fake(fn (string $prompt) => "Echo: {$prompt}"); +test('anthropic agent fake with closure', function (): void { + AnthropicAgent::fake(fn (string $prompt): string => "Echo: {$prompt}"); $response = (new AnthropicAgent)->prompt('Hello world'); expect($response->text)->toBe('Echo: Hello world'); }); -test('anthropic agent fake with no predefined responses', function () { +test('anthropic agent fake with no predefined responses', function (): void { AnthropicAgent::fake(); $response = (new AnthropicAgent)->prompt('Hello'); @@ -26,7 +26,7 @@ expect($response->text)->toBe('Fake response for prompt: Hello'); }); -test('anthropic agent fake records prompts', function () { +test('anthropic agent fake records prompts', function (): void { AnthropicAgent::fake(); (new AnthropicAgent)->prompt('Hello'); @@ -35,11 +35,11 @@ AnthropicAgent::assertNotPrompted('Goodbye'); }); -test('anthropic agent stream can be faked', function () { +test('anthropic agent stream can be faked', function (): void { AnthropicAgent::fake(['Streamed response']); $response = (new AnthropicAgent)->stream('Hello'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->text)->toBe('Streamed response'); }); diff --git a/tests/Feature/Providers/Anthropic/BaseUrlTest.php b/tests/Feature/Providers/Anthropic/BaseUrlTest.php index d022033a7..b708223f0 100644 --- a/tests/Feature/Providers/Anthropic/BaseUrlTest.php +++ b/tests/Feature/Providers/Anthropic/BaseUrlTest.php @@ -3,7 +3,7 @@ use Illuminate\Support\Facades\Http; use Tests\Fixtures\Agents\AssistantAgent; -test('anthropic requests use the configured base url', function () { +test('anthropic requests use the configured base url', function (): void { config(['ai.providers.anthropic.url' => 'https://custom-proxy.example.com/v1']); Http::fake([ @@ -15,12 +15,10 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { - return $request->url() === 'https://custom-proxy.example.com/v1/messages'; - }); + Http::assertSent(fn ($request): bool => $request->url() === 'https://custom-proxy.example.com/v1/messages'); }); -test('anthropic requests fall back to the default base url', function () { +test('anthropic requests fall back to the default base url', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -30,7 +28,5 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { - return $request->url() === 'https://api.anthropic.com/v1/messages'; - }); + Http::assertSent(fn ($request): bool => $request->url() === 'https://api.anthropic.com/v1/messages'); }); diff --git a/tests/Feature/Providers/Anthropic/ErrorHandlingTest.php b/tests/Feature/Providers/Anthropic/ErrorHandlingTest.php index 5c49b6e6e..b2a0a297b 100644 --- a/tests/Feature/Providers/Anthropic/ErrorHandlingTest.php +++ b/tests/Feature/Providers/Anthropic/ErrorHandlingTest.php @@ -8,7 +8,7 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Tests\Fixtures\Agents\AssistantAgent; -test('http error response throws request exception', function () { +test('http error response throws request exception', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response([ 'type' => 'error', @@ -25,7 +25,7 @@ ); })->throws(RequestException::class); -test('rate limit response throws rate limited exception', function () { +test('rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response([ 'type' => 'error', @@ -42,7 +42,7 @@ ); })->throws(RateLimitedException::class); -test('insufficient credit response throws insufficient credits exception', function (string $message) { +test('insufficient credit response throws insufficient credits exception', function (string $message): void { Http::fake([ 'api.anthropic.com/*' => Http::response([ 'type' => 'error', @@ -65,7 +65,7 @@ 'billing' => ['There is a billing issue with your account; please update your payment method.'], ])->throws(InsufficientCreditsException::class); -test('error in 200 response throws ai exception', function () { +test('error in 200 response throws ai exception', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response([ 'type' => 'error', @@ -82,7 +82,7 @@ ); })->throws(AiException::class, 'api_error'); -test('529 overloaded response throws provider overloaded exception', function () { +test('529 overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response([ 'type' => 'error', diff --git a/tests/Feature/Providers/Anthropic/FileGatewayTest.php b/tests/Feature/Providers/Anthropic/FileGatewayTest.php index b8915aff6..50e2a37fd 100644 --- a/tests/Feature/Providers/Anthropic/FileGatewayTest.php +++ b/tests/Feature/Providers/Anthropic/FileGatewayTest.php @@ -5,14 +5,14 @@ use Laravel\Ai\Files; use Laravel\Ai\Files\Document; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.anthropic' => [ ...config('ai.providers.anthropic'), 'key' => 'test-key', ]]); }); -test('get file sends correct request', function () { +test('get file sends correct request', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response(['id' => 'file-abc123', 'mime_type' => 'text/plain']), ]); @@ -21,12 +21,12 @@ expect($response->id)->toBe('file-abc123'); - Http::assertSent(fn (Request $request) => $request->method() === 'GET' + Http::assertSent(fn (Request $request): bool => $request->method() === 'GET' && $request->url() === 'https://api.anthropic.com/v1/files/file-abc123' && $request->hasHeader('x-api-key', 'test-key')); }); -test('put file sends multipart upload', function () { +test('put file sends multipart upload', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response(['id' => 'file-uploaded123']), ]); @@ -45,7 +45,7 @@ ->and($request->hasHeader('x-api-key', 'test-key'))->toBeTrue(); }); -test('put file forwards provider options into the multipart upload', function () { +test('put file forwards provider options into the multipart upload', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response(['id' => 'file-uploaded123']), ]); @@ -57,14 +57,14 @@ expect(multipartField(sentRequest(), 'custom_field'))->toBe('value'); }); -test('delete file sends correct request', function () { +test('delete file sends correct request', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response(['id' => 'file-abc123']), ]); Files::delete('file-abc123', provider: 'anthropic'); - Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' + Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' && $request->url() === 'https://api.anthropic.com/v1/files/file-abc123' && $request->hasHeader('x-api-key', 'test-key')); }); diff --git a/tests/Feature/Providers/Anthropic/MessageMappingTest.php b/tests/Feature/Providers/Anthropic/MessageMappingTest.php index fafc6a94d..cfd321c87 100644 --- a/tests/Feature/Providers/Anthropic/MessageMappingTest.php +++ b/tests/Feature/Providers/Anthropic/MessageMappingTest.php @@ -14,7 +14,7 @@ use function Laravel\Ai\agent; -test('user message maps to anthropic format', function () { +test('user message maps to anthropic format', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -24,7 +24,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $messages = $request->data()['messages']; $userMessage = $messages[0]; @@ -34,7 +34,7 @@ }); }); -test('tool result follow up maps assistant and tool result messages', function () { +test('tool result follow up maps assistant and tool result messages', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::sequence([ $this->fakeToolCallResponse(), @@ -86,7 +86,7 @@ ->and($toolResultBlock['content'])->not->toBeEmpty(); }); -test('local image attachment without explicit mime type detects mime from file', function () { +test('local image attachment without explicit mime type detects mime from file', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('I see an image'), ]); @@ -97,7 +97,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $content = $request->data()['messages'][0]['content']; $imageBlock = collect($content)->firstWhere('type', 'image'); @@ -107,7 +107,7 @@ }); }); -test('base64 pdf document maps to document content block', function () { +test('base64 pdf document maps to document content block', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('I see a PDF'), ]); @@ -120,7 +120,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $content = $request->data()['messages'][0]['content']; $docBlock = $content[0]; @@ -131,7 +131,7 @@ }); }); -test('base64 text document maps to text source block', function () { +test('base64 text document maps to text source block', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -144,7 +144,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $docBlock = $request->data()['messages'][0]['content'][0]; return $docBlock['type'] === 'document' @@ -154,7 +154,7 @@ }); }); -test('stored text document maps to text source block', function () { +test('stored text document maps to text source block', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -168,7 +168,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $docBlock = $request->data()['messages'][0]['content'][0]; return $docBlock['type'] === 'document' @@ -178,7 +178,7 @@ }); }); -test('local text document maps to text source block', function () { +test('local text document maps to text source block', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -193,12 +193,12 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $docBlock = $request->data()['messages'][0]['content'][0]; return $docBlock['type'] === 'document' && $docBlock['source']['type'] === 'text' - && str_starts_with($docBlock['source']['media_type'], 'text/') + && str_starts_with((string) $docBlock['source']['media_type'], 'text/') && $docBlock['source']['data'] === 'local text contents'; }); } finally { @@ -206,7 +206,7 @@ } }); -test('uploaded text file maps to text source block', function () { +test('uploaded text file maps to text source block', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -219,7 +219,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $docBlock = $request->data()['messages'][0]['content'][0]; return $docBlock['type'] === 'document' @@ -228,7 +228,7 @@ }); }); -test('uploaded pdf file maps to document content block', function () { +test('uploaded pdf file maps to document content block', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('I see a PDF'), ]); @@ -241,7 +241,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $content = $request->data()['messages'][0]['content']; $docBlock = $content[0]; @@ -251,7 +251,7 @@ }); }); -test('empty tool arguments serialize as object on assistant replay', function () { +test('empty tool arguments serialize as object on assistant replay', function (): void { $assistant = new AssistantMessage('Listing.', collect([ new ToolCall( id: 'toolu_empty', @@ -262,7 +262,6 @@ $gateway = app(AnthropicGateway::class); $method = (new ReflectionClass($gateway))->getMethod('mapMessages'); - $method->setAccessible(true); $mapped = $method->invoke($gateway, [$assistant]); $toolUse = collect($mapped[0]['content'])->firstWhere('type', 'tool_use'); @@ -271,7 +270,7 @@ ->and(get_object_vars($toolUse['input']))->toBeEmpty(); }); -test('non-empty tool arguments preserve shape on assistant replay', function () { +test('non-empty tool arguments preserve shape on assistant replay', function (): void { $assistant = new AssistantMessage('Searching.', collect([ new ToolCall( id: 'toolu_args', @@ -282,7 +281,6 @@ $gateway = app(AnthropicGateway::class); $method = (new ReflectionClass($gateway))->getMethod('mapMessages'); - $method->setAccessible(true); $mapped = $method->invoke($gateway, [$assistant]); $toolUse = collect($mapped[0]['content'])->firstWhere('type', 'tool_use'); @@ -290,7 +288,7 @@ expect($toolUse['input'])->toBe(['query' => 'test']); }); -test('assistant message with provider content blocks is replayed verbatim preserving order', function () { +test('assistant message with provider content blocks is replayed verbatim preserving order', function (): void { $contentBlocks = [ ['type' => 'text', 'text' => 'Let me consult the advisor.'], [ @@ -316,11 +314,10 @@ ['type' => 'text', 'text' => "Here's the implementation."], ]; - $assistant = new AssistantMessage('Here\'s the implementation.', null, $contentBlocks); + $assistant = new AssistantMessage("Here's the implementation.", null, $contentBlocks); $gateway = app(AnthropicGateway::class); $method = (new ReflectionClass($gateway))->getMethod('mapMessages'); - $method->setAccessible(true); $mapped = $method->invoke($gateway, [$assistant]); @@ -338,7 +335,7 @@ expect($serverToolUse['input'])->toBeInstanceOf(stdClass::class); }); -test('parsed response populates provider content blocks on the assistant message', function () { +test('parsed response populates provider content blocks on the assistant message', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response([ 'id' => 'msg_1', @@ -386,7 +383,7 @@ ->and($blocks[3])->toBe(['type' => 'text', 'text' => 'Done.']); }); -test('assistant message produced by parser round-trips through mapping with server blocks intact', function () { +test('assistant message produced by parser round-trips through mapping with server blocks intact', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response([ 'id' => 'msg_1', @@ -418,7 +415,6 @@ $gateway = app(AnthropicGateway::class); $method = (new ReflectionClass($gateway))->getMethod('mapMessages'); - $method->setAccessible(true); $mapped = $method->invoke($gateway, [$assistant]); $content = $mapped[0]['content']; @@ -440,12 +436,11 @@ ->and($content[3])->toBe(['type' => 'text', 'text' => 'Found it.']); }); -test('assistant message without provider content blocks falls back to text plus tool calls rebuild', function () { +test('assistant message without provider content blocks falls back to text plus tool calls rebuild', function (): void { $assistant = new AssistantMessage('Hello'); $gateway = app(AnthropicGateway::class); $method = (new ReflectionClass($gateway))->getMethod('mapMessages'); - $method->setAccessible(true); $mapped = $method->invoke($gateway, [$assistant]); @@ -455,7 +450,7 @@ ]); }); -test('thinking and redacted_thinking blocks are preserved on replay', function () { +test('thinking and redacted_thinking blocks are preserved on replay', function (): void { $contentBlocks = [ ['type' => 'thinking', 'thinking' => 'Considering options.', 'signature' => 'sig_1'], ['type' => 'redacted_thinking', 'data' => 'opaque'], @@ -466,14 +461,13 @@ $gateway = app(AnthropicGateway::class); $method = (new ReflectionClass($gateway))->getMethod('mapMessages'); - $method->setAccessible(true); $mapped = $method->invoke($gateway, [$assistant]); expect($mapped[0]['content'])->toBe($contentBlocks); }); -test('system instructions are not in messages array', function () { +test('system instructions are not in messages array', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -483,7 +477,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); foreach ($body['messages'] as $message) { diff --git a/tests/Feature/Providers/Anthropic/ProviderOptionsTest.php b/tests/Feature/Providers/Anthropic/ProviderOptionsTest.php index 319d8bc5d..7034b3edc 100644 --- a/tests/Feature/Providers/Anthropic/ProviderOptionsTest.php +++ b/tests/Feature/Providers/Anthropic/ProviderOptionsTest.php @@ -5,7 +5,7 @@ use Tests\Fixtures\Agents\ProviderOptionsAgent; use Tests\Fixtures\Agents\ProviderOptionsWithToolsAgent; -test('provider options are included in anthropic request body', function () { +test('provider options are included in anthropic request body', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -15,7 +15,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); return isset($body['thinking']) @@ -24,7 +24,7 @@ }); }); -test('request body does not contain provider options when agent does not implement interface', function () { +test('request body does not contain provider options when agent does not implement interface', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -34,12 +34,10 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { - return ! isset($request->data()['thinking']); - }); + Http::assertSent(fn ($request): bool => ! isset($request->data()['thinking'])); }); -test('provider options are persisted in tool call follow up requests', function () { +test('provider options are persisted in tool call follow up requests', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::sequence([ $this->fakeToolCallResponse(), diff --git a/tests/Feature/Providers/Anthropic/RequestMappingTest.php b/tests/Feature/Providers/Anthropic/RequestMappingTest.php index eb70d3254..759fef799 100644 --- a/tests/Feature/Providers/Anthropic/RequestMappingTest.php +++ b/tests/Feature/Providers/Anthropic/RequestMappingTest.php @@ -1,6 +1,7 @@ $this->fakeTextResponse('Laravel is great'), ]); @@ -22,7 +23,7 @@ model: 'claude-sonnet-4-6', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); return $request->url() === 'https://api.anthropic.com/v1/messages' @@ -32,7 +33,7 @@ }); }); - test('system instructions are sent as top level system field', function () { + test('system instructions are sent as top level system field', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -42,7 +43,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); return isset($body['system']) @@ -51,7 +52,7 @@ }); }); - test('max tokens defaults to 64000', function () { + test('max tokens defaults to 64000', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -61,12 +62,10 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { - return $request->data()['max_tokens'] === 64000; - }); + Http::assertSent(fn ($request): bool => $request->data()['max_tokens'] === 64000); }); - test('temperature and top_p are included when set via attributes', function () { + test('temperature and top_p are included when set via attributes', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -76,7 +75,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); return $body['temperature'] === 0.7 @@ -84,7 +83,7 @@ }); }); - test('temperature and top_p are excluded when not set', function () { + test('temperature and top_p are excluded when not set', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -94,7 +93,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); return ! array_key_exists('temperature', $body) @@ -102,7 +101,7 @@ }); }); - test('tools with structured output use tool choice any when native structured output is disabled', function () { + test('tools with structured output use tool choice any when native structured output is disabled', function (): void { config(['ai.providers.anthropic' => [ ...config('ai.providers.anthropic'), 'use_native_structured_output' => false, @@ -117,7 +116,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); return isset($body['tools']) @@ -126,7 +125,7 @@ }); }); - test('request without tools excludes tool fields', function () { + test('request without tools excludes tool fields', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -136,7 +135,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); return ! isset($body['tools']) @@ -144,7 +143,7 @@ }); }); - test('request sends correct authentication headers', function () { + test('request sends correct authentication headers', function (): void { config(['ai.providers.anthropic' => [ ...config('ai.providers.anthropic'), 'key' => 'test-key', @@ -159,13 +158,11 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { - return $request->hasHeader('x-api-key', 'test-key') - && $request->hasHeader('anthropic-version', '2023-06-01'); - }); + Http::assertSent(fn ($request): bool => $request->hasHeader('x-api-key', 'test-key') + && $request->hasHeader('anthropic-version', '2023-06-01')); }); - test('request omits the api key header when no key is configured', function () { + test('request omits the api key header when no key is configured', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -175,15 +172,13 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { - return ! $request->hasHeader('x-api-key') - && $request->hasHeader('anthropic-version', '2023-06-01'); - }); + Http::assertSent(fn ($request): bool => ! $request->hasHeader('x-api-key') + && $request->hasHeader('anthropic-version', '2023-06-01')); }); }); -describe('structured output', function () { - test('structured output uses native output_config by default', function () { +describe('structured output', function (): void { + test('structured output uses native output_config by default', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeStructuredResponse(['name' => 'Taylor', 'age' => 30]), ]); @@ -193,7 +188,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); $hasStructuredTool = false; @@ -209,7 +204,7 @@ }); }); - test('structured output falls back to the synthetic tool when native structured output is disabled', function () { + test('structured output falls back to the synthetic tool when native structured output is disabled', function (): void { config(['ai.providers.anthropic' => [ ...config('ai.providers.anthropic'), 'use_native_structured_output' => false, @@ -224,7 +219,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); $hasStructuredTool = false; @@ -241,7 +236,7 @@ }); }); - test('structured output with thinking uses auto tool choice when native structured output is disabled', function () { + test('structured output with thinking uses auto tool choice when native structured output is disabled', function (): void { config(['ai.providers.anthropic' => [ ...config('ai.providers.anthropic'), 'use_native_structured_output' => false, @@ -256,7 +251,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); $hasStructuredTool = false; @@ -273,7 +268,7 @@ }); }); - test('native structured response is correctly parsed', function () { + test('native structured response is correctly parsed', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeStructuredResponse(['name' => 'Taylor', 'age' => 30]), ]); @@ -285,7 +280,7 @@ expect($response->structured)->toMatchArray(['name' => 'Taylor', 'age' => 30]); }); - test('synthetic tool structured response is correctly parsed when native structured output is disabled', function () { + test('synthetic tool structured response is correctly parsed when native structured output is disabled', function (): void { config(['ai.providers.anthropic' => [ ...config('ai.providers.anthropic'), 'use_native_structured_output' => false, @@ -303,8 +298,8 @@ }); }); -describe('response parsing', function () { - test('response text is correctly parsed', function () { +describe('response parsing', function (): void { + test('response text is correctly parsed', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('Laravel is a PHP framework'), ]); @@ -317,7 +312,7 @@ expect($response->text)->toBe('Laravel is a PHP framework'); }); - test('response usage is correctly parsed', function () { + test('response usage is correctly parsed', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response([ 'id' => 'msg_123', @@ -346,61 +341,53 @@ }); }); -describe('tool choice', function () { - test('required tool choice maps to any', function () { +describe('tool choice', function (): void { + test('required tool choice maps to any', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('The number is 42'), ]); (new ToolChoiceAgent('required'))->prompt('Generate a number', provider: 'anthropic'); - Http::assertSent(function ($request) { - return $request->data()['tool_choice'] === ['type' => 'any']; - }); + Http::assertSent(fn ($request): bool => $request->data()['tool_choice'] === ['type' => 'any']); }); - test('required tool choice can be set via attribute', function () { + test('required tool choice can be set via attribute', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('The number is 42'), ]); (new AttributeToolChoiceAgent)->prompt('Generate a number', provider: 'anthropic'); - Http::assertSent(function ($request) { - return $request->data()['tool_choice'] === ['type' => 'any']; - }); + Http::assertSent(fn ($request): bool => $request->data()['tool_choice'] === ['type' => 'any']); }); - test('named tool choice maps to a specific tool', function () { + test('named tool choice maps to a specific tool', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('The number is 42'), ]); (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Generate a number', provider: 'anthropic'); - Http::assertSent(function ($request) { - return $request->data()['tool_choice'] === ['type' => 'tool', 'name' => 'custom_named_tool']; - }); + Http::assertSent(fn ($request): bool => $request->data()['tool_choice'] === ['type' => 'tool', 'name' => 'custom_named_tool']); }); - test('none tool choice prevents tool calls', function () { + test('none tool choice prevents tool calls', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('Sure'), ]); (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'anthropic'); - Http::assertSent(function ($request) { - return $request->data()['tool_choice'] === ['type' => 'none']; - }); + Http::assertSent(fn ($request): bool => $request->data()['tool_choice'] === ['type' => 'none']); }); - test('forcing a tool while thinking is enabled throws', function () { + test('forcing a tool while thinking is enabled throws', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('The number is 42'), ]); - expect(fn () => (new ThinkingToolChoiceAgent)->prompt('Generate a number', provider: 'anthropic')) + expect(fn (): AgentResponse => (new ThinkingToolChoiceAgent)->prompt('Generate a number', provider: 'anthropic')) ->toThrow( InvalidArgumentException::class, 'Anthropic cannot force tool use while extended thinking is enabled.', diff --git a/tests/Feature/Providers/Anthropic/StreamingTest.php b/tests/Feature/Providers/Anthropic/StreamingTest.php index 803ff0c73..9998c9290 100644 --- a/tests/Feature/Providers/Anthropic/StreamingTest.php +++ b/tests/Feature/Providers/Anthropic/StreamingTest.php @@ -15,8 +15,8 @@ use Laravel\Ai\Streaming\Events\ToolCall as ToolCallEvent; use Tests\Fixtures\Agents\ProviderOptionsWithToolsAgent; -describe('text streaming', function () { - test('streaming emits text events', function () { +describe('text streaming', function (): void { + test('streaming emits text events', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response( body: $this->ssePayload([ @@ -43,8 +43,8 @@ }); }); -describe('tool calls', function () { - test('streaming handles tool calls', function () { +describe('tool calls', function (): void { + test('streaming handles tool calls', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::sequence([ Http::response( @@ -72,15 +72,15 @@ $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); - $toolCallEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolCallEvent)); + $toolCallEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolCallEvent)); expect($toolCallEvents)->not->toBeEmpty() ->and($toolCallEvents[0]->toolCall)->name->toBe('FixedNumberGenerator')->id->toBe('toolu_1'); }); }); -describe('thinking blocks', function () { - test('streaming handles thinking blocks', function () { +describe('thinking blocks', function (): void { + test('streaming handles thinking blocks', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response( body: $this->ssePayload([ @@ -106,11 +106,11 @@ ReasoningEnd::class, ]); - $reasoningDelta = array_values(array_filter($events, fn ($e) => $e instanceof ReasoningDelta))[0]; + $reasoningDelta = array_values(array_filter($events, fn ($e): bool => $e instanceof ReasoningDelta))[0]; expect($reasoningDelta->delta)->toBe('Let me think...'); }); - test('streaming handles server tool use', function () { + test('streaming handles server tool use', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response( body: $this->ssePayload([ @@ -129,14 +129,14 @@ $events = $this->collectStreamEvents(); - $providerEvents = array_values(array_filter($events, fn ($e) => $e instanceof ProviderToolEvent)); + $providerEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ProviderToolEvent)); expect($providerEvents)->toHaveCount(2) ->and($providerEvents[0])->status->toBe('started')->itemId->toBe('srvtoolu_1') ->and($providerEvents[1]->status)->toBe('completed'); }); - test('streaming handles provider tool results', function () { + test('streaming handles provider tool results', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response( body: $this->ssePayload([ @@ -155,15 +155,15 @@ $events = $this->collectStreamEvents(); - $providerEvents = array_values(array_filter($events, fn ($e) => $e instanceof ProviderToolEvent)); + $providerEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ProviderToolEvent)); expect($providerEvents)->not->toBeEmpty() ->and($providerEvents[0])->status->toBe('result_received')->type->toBe('web_search_tool_result'); }); }); -describe('pause_turn', function () { - test('streaming pause_turn triggers follow-up stream with assistant replayed', function () { +describe('pause_turn', function (): void { + test('streaming pause_turn triggers follow-up stream with assistant replayed', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::sequence([ Http::response( @@ -204,14 +204,14 @@ ->and($lastMessage['content'][0]['input'])->toBeInstanceOf(stdClass::class) ->and($lastMessage['content'][0]['input']->query)->toBe('laravel ai'); - $textDeltas = array_values(array_filter($events, fn ($e) => $e instanceof TextDelta)); + $textDeltas = array_values(array_filter($events, fn ($e): bool => $e instanceof TextDelta)); expect($textDeltas)->not->toBeEmpty() ->and($textDeltas[0]->delta)->toBe('Resumed'); }); }); -describe('error handling', function () { - test('streaming error event stops stream', function () { +describe('error handling', function (): void { + test('streaming error event stops stream', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response( body: $this->ssePayload([ @@ -229,8 +229,8 @@ }); }); -describe('usage tracking', function () { - test('streaming captures input tokens from message start', function () { +describe('usage tracking', function (): void { + test('streaming captures input tokens from message start', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::response( body: $this->ssePayload([ @@ -261,7 +261,7 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->usage) ->promptTokens->toBe(42) @@ -270,7 +270,7 @@ ->cacheReadInputTokens->toBe(50); }); - test('streaming finish reason maps correctly', function (string $apiReason, $expected) { + test('streaming finish reason maps correctly', function (string $apiReason, $expected): void { Http::fake([ 'api.anthropic.com/*' => Http::response( body: $this->ssePayload([ @@ -287,7 +287,7 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->reason)->toBe($expected->value); })->with([ diff --git a/tests/Feature/Providers/Anthropic/ToolCallLoopTest.php b/tests/Feature/Providers/Anthropic/ToolCallLoopTest.php index d13eb1d18..821e7b68d 100644 --- a/tests/Feature/Providers/Anthropic/ToolCallLoopTest.php +++ b/tests/Feature/Providers/Anthropic/ToolCallLoopTest.php @@ -3,7 +3,7 @@ use Illuminate\Support\Facades\Http; use Tests\Fixtures\Agents\ToolUsingAgent; -test('tool calls trigger follow up request', function () { +test('tool calls trigger follow up request', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::sequence([ $this->fakeUniqueToolCallResponse(), @@ -47,7 +47,7 @@ ->and($hasToolResult)->toBeTrue('Follow-up request should include user message with tool_result block'); }); -test('server_tool_use input is serialized as object on follow-up replay', function () { +test('server_tool_use input is serialized as object on follow-up replay', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::sequence([ Http::response([ @@ -75,7 +75,7 @@ ->not->toContain('"input":[]'); }); -test('pause_turn resumes when last block is text following a web_search_tool_result', function () { +test('pause_turn resumes when last block is text following a web_search_tool_result', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::sequence([ Http::response([ @@ -112,7 +112,7 @@ expect(Http::recorded())->toHaveCount(2); }); -test('full compose: pause_turn + server_tool_use replay with input cast and block order preserved', function () { +test('full compose: pause_turn + server_tool_use replay with input cast and block order preserved', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::sequence([ Http::response([ @@ -155,8 +155,8 @@ $recorded = Http::recorded(); expect($recorded)->toHaveCount(2); - $payload = json_decode($recorded[1][0]->body(), false, 512, JSON_THROW_ON_ERROR); - $assistant = collect($payload->messages)->first(fn ($m) => $m->role === 'assistant'); + $payload = json_decode((string) $recorded[1][0]->body(), false, 512, JSON_THROW_ON_ERROR); + $assistant = collect($payload->messages)->first(fn ($m): bool => $m->role === 'assistant'); expect(array_column((array) $assistant->content, 'type'))->toBe([ 'server_tool_use', @@ -171,7 +171,7 @@ ->and($serverBlocks[1]->input)->toBeInstanceOf(stdClass::class); }); -test('max steps limits tool call depth', function () { +test('max steps limits tool call depth', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::sequence([ $this->fakeUniqueToolCallResponse(), diff --git a/tests/Feature/Providers/Anthropic/ToolMappingTest.php b/tests/Feature/Providers/Anthropic/ToolMappingTest.php index 575d7d48e..eab8056d3 100644 --- a/tests/Feature/Providers/Anthropic/ToolMappingTest.php +++ b/tests/Feature/Providers/Anthropic/ToolMappingTest.php @@ -9,7 +9,7 @@ use function Laravel\Ai\agent; -test('tool parameters are not wrapped in schema definition', function () { +test('tool parameters are not wrapped in schema definition', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('The number is 42'), ]); @@ -19,7 +19,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $tools = $request->data()['tools'] ?? []; foreach ($tools as $tool) { @@ -35,7 +35,7 @@ }); }); -test('unsupported provider tool throws logic exception', function () { +test('unsupported provider tool throws logic exception', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse(), ]); @@ -49,21 +49,21 @@ ); })->throws(LogicException::class, 'is not supported by Anthropic'); -test('tool with a name() method emits the declared name', function () { +test('tool with a name() method emits the declared name', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('ok'), ]); (new NamedToolAgent('aliased_tool'))->prompt('Search', provider: 'anthropic'); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $names = collect($request->data()['tools'] ?? [])->pluck('name')->all(); return in_array('aliased_tool', $names, true); }); }); -test('web search tool sends allowed_domains', function () { +test('web search tool sends allowed_domains', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('ok'), ]); @@ -71,14 +71,14 @@ agent(tools: [(new WebSearch)->allow(['laravel.com', 'php.net'])]) ->prompt('Search', provider: 'anthropic'); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $tool = collect($request->data()['tools'] ?? [])->firstWhere('name', 'web_search'); return data_get($tool, 'allowed_domains') === ['laravel.com', 'php.net']; }); }); -test('web search tool forwards anthropic provider options into the tool payload', function () { +test('web search tool forwards anthropic provider options into the tool payload', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('ok'), ]); @@ -87,7 +87,7 @@ (new WebSearch)->withProviderOptions(['blocked_domains' => ['spam.com']]), ])->prompt('Search', provider: 'anthropic'); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $tool = collect($request->data()['tools'] ?? [])->firstWhere('name', 'web_search'); return data_get($tool, 'blocked_domains') === ['spam.com']; @@ -169,7 +169,7 @@ }); }); -test('empty schema still includes input schema with type object', function () { +test('empty schema still includes input schema with type object', function (): void { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('The number is 42'), ]); @@ -179,7 +179,7 @@ provider: 'anthropic', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $tools = $request->data()['tools'] ?? []; foreach ($tools as $tool) { diff --git a/tests/Feature/Providers/AzureOpenAi/AgentFakeTest.php b/tests/Feature/Providers/AzureOpenAi/AgentFakeTest.php index 03f6fb0ca..36c615225 100644 --- a/tests/Feature/Providers/AzureOpenAi/AgentFakeTest.php +++ b/tests/Feature/Providers/AzureOpenAi/AgentFakeTest.php @@ -2,7 +2,7 @@ use Tests\Fixtures\Agents\AzureAgent; -test('azure agent can be faked', function () { +test('azure agent can be faked', function (): void { AzureAgent::fake(['Test response']); $response = (new AzureAgent)->prompt('Hello'); @@ -10,15 +10,15 @@ expect($response->text)->toBe('Test response'); }); -test('azure agent fake with closure', function () { - AzureAgent::fake(fn (string $prompt) => "Echo: {$prompt}"); +test('azure agent fake with closure', function (): void { + AzureAgent::fake(fn (string $prompt): string => "Echo: {$prompt}"); $response = (new AzureAgent)->prompt('Hello world'); expect($response->text)->toBe('Echo: Hello world'); }); -test('azure agent fake with no predefined responses', function () { +test('azure agent fake with no predefined responses', function (): void { AzureAgent::fake(); $response = (new AzureAgent)->prompt('Hello'); @@ -26,7 +26,7 @@ expect($response->text)->toBe('Fake response for prompt: Hello'); }); -test('azure agent fake records prompts', function () { +test('azure agent fake records prompts', function (): void { AzureAgent::fake(); (new AzureAgent)->prompt('Hello'); @@ -35,11 +35,11 @@ AzureAgent::assertNotPrompted('Goodbye'); }); -test('azure agent stream can be faked', function () { +test('azure agent stream can be faked', function (): void { AzureAgent::fake(['Streamed response']); $response = (new AzureAgent)->stream('Hello'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->text)->toBe('Streamed response'); }); diff --git a/tests/Feature/Providers/AzureOpenAi/BaseUrlTest.php b/tests/Feature/Providers/AzureOpenAi/BaseUrlTest.php index 32b08673f..e2206f705 100644 --- a/tests/Feature/Providers/AzureOpenAi/BaseUrlTest.php +++ b/tests/Feature/Providers/AzureOpenAi/BaseUrlTest.php @@ -5,7 +5,7 @@ use function Laravel\Ai\agent; -test('azure text requests use the v1 responses endpoint', function () { +test('azure text requests use the v1 responses endpoint', function (): void { configureAzureProvider('https://my-resource.cognitiveservices.azure.com', deployment: 'gpt-4o'); Http::fake(['*' => fakeAzureResponse('Hello from Azure')]); @@ -17,29 +17,25 @@ azureAssertRequestSent('POST', 'https://my-resource.cognitiveservices.azure.com/openai/v1/responses'); }); -test('azure requests do not include api-version query parameter', function () { +test('azure requests do not include api-version query parameter', function (): void { configureAzureProvider('https://my-resource.cognitiveservices.azure.com'); Http::fake(['*' => fakeAzureResponse()]); agent()->prompt('Hello', provider: 'azure'); - Http::assertSent(function (Request $request) { - return ! str_contains($request->url(), 'api-version'); - }); + Http::assertSent(fn (Request $request): bool => ! str_contains($request->url(), 'api-version')); }); -test('azure requests use api-key header not bearer token', function () { +test('azure requests use api-key header not bearer token', function (): void { configureAzureProvider('https://my-resource.cognitiveservices.azure.com'); Http::fake(['*' => fakeAzureResponse()]); agent()->prompt('Hello', provider: 'azure'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('api-key', 'test-key') - && ! $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request): bool => $request->hasHeader('api-key', 'test-key') + && ! $request->hasHeader('Authorization', 'Bearer test-key')); }); function configureAzureProvider(?string $url = null, ?string $apiVersion = null, string $deployment = 'gpt-4o'): void @@ -54,7 +50,7 @@ function configureAzureProvider(?string $url = null, ?string $apiVersion = null, function azureAssertRequestSent(string $method, string $url): void { - Http::assertSent(function (Request $request) use ($method, $url) { + Http::assertSent(function (Request $request) use ($method, $url): bool { $requestUrl = strtok($request->url(), '?'); return $request->method() === $method diff --git a/tests/Feature/Providers/AzureOpenAi/EmbeddingTest.php b/tests/Feature/Providers/AzureOpenAi/EmbeddingTest.php index 43003c4ff..89f54192e 100644 --- a/tests/Feature/Providers/AzureOpenAi/EmbeddingTest.php +++ b/tests/Feature/Providers/AzureOpenAi/EmbeddingTest.php @@ -7,7 +7,7 @@ use Laravel\Ai\Exceptions\ProviderOverloadedException; use Laravel\Ai\Exceptions\RateLimitedException; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.azure' => [ ...config('ai.providers.azure'), 'key' => 'test-key', @@ -17,12 +17,12 @@ ]]); }); -test('embeddings request includes model input and dimensions', function () { +test('embeddings request includes model input and dimensions', function (): void { Http::fake(['*' => fakeAzureEmbeddingsResponse()]); Embeddings::for(['Hello world'])->dimensions(1536)->generate(provider: 'azure', model: 'text-embedding-3-small'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'text-embedding-3-small' @@ -31,17 +31,15 @@ }); }); -test('embeddings request uses the v1 embeddings endpoint', function () { +test('embeddings request uses the v1 embeddings endpoint', function (): void { Http::fake(['*' => fakeAzureEmbeddingsResponse()]); Embeddings::for(['Hello'])->generate(provider: 'azure', model: 'text-embedding-3-small'); - Http::assertSent(function (Request $request) { - return str_contains($request->url(), '/openai/v1/embeddings'); - }); + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '/openai/v1/embeddings')); }); -test('embeddings response is correctly parsed', function () { +test('embeddings response is correctly parsed', function (): void { Http::fake(['*' => fakeAzureEmbeddingsResponse()]); $response = Embeddings::for(['Hello world'])->generate(provider: 'azure', model: 'text-embedding-3-small'); @@ -52,27 +50,23 @@ ->and($response->meta->provider)->toBe('azure'); }); -test('embeddings request sends api-key header', function () { +test('embeddings request sends api-key header', function (): void { Http::fake(['*' => fakeAzureEmbeddingsResponse()]); Embeddings::for(['Hello'])->generate(provider: 'azure', model: 'text-embedding-3-small'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('api-key', 'test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('api-key', 'test-key')); }); -test('embeddings request does not include api-version query parameter', function () { +test('embeddings request does not include api-version query parameter', function (): void { Http::fake(['*' => fakeAzureEmbeddingsResponse()]); Embeddings::for(['Hello'])->generate(provider: 'azure', model: 'text-embedding-3-small'); - Http::assertSent(function (Request $request) { - return ! str_contains($request->url(), 'api-version'); - }); + Http::assertSent(fn (Request $request): bool => ! str_contains($request->url(), 'api-version')); }); -test('multiple inputs return multiple embeddings', function () { +test('multiple inputs return multiple embeddings', function (): void { Http::fake(['*' => Http::response([ 'id' => 'embd-123', 'object' => 'list', @@ -89,7 +83,7 @@ expect($response->embeddings)->toHaveCount(2); }); -test('embeddings rate limit response throws rate limited exception', function () { +test('embeddings rate limit response throws rate limited exception', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::response([ 'error' => [ @@ -102,7 +96,7 @@ Embeddings::for(['Hello'])->generate(provider: 'azure', model: 'text-embedding-3-small'); })->throws(RateLimitedException::class); -test('embeddings overloaded response throws provider overloaded exception', function () { +test('embeddings overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::response([ 'error' => [ @@ -115,7 +109,7 @@ Embeddings::for(['Hello'])->generate(provider: 'azure', model: 'text-embedding-3-small'); })->throws(ProviderOverloadedException::class); -test('embeddings http error response throws request exception', function () { +test('embeddings http error response throws request exception', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::response([ 'error' => [ @@ -128,14 +122,14 @@ Embeddings::for(['Hello'])->generate(provider: 'azure', model: 'text-embedding-3-small'); })->throws(RequestException::class); -test('embeddings request includes provider options in the request body', function () { +test('embeddings request includes provider options in the request body', function (): void { Http::fake(['*' => fakeAzureEmbeddingsResponse()]); Embeddings::for(['Hello']) ->withProviderOptions(['encoding_format' => 'base64', 'user' => 'tester']) ->generate(provider: 'azure', model: 'text-embedding-3-small'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['encoding_format'] === 'base64' diff --git a/tests/Feature/Providers/AzureOpenAi/ErrorHandlingTest.php b/tests/Feature/Providers/AzureOpenAi/ErrorHandlingTest.php index 1a03a25be..5c4d5b28c 100644 --- a/tests/Feature/Providers/AzureOpenAi/ErrorHandlingTest.php +++ b/tests/Feature/Providers/AzureOpenAi/ErrorHandlingTest.php @@ -7,7 +7,7 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Tests\Fixtures\Agents\AssistantAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.azure' => [ ...config('ai.providers.azure'), 'key' => 'test-key', @@ -16,7 +16,7 @@ ]]); }); -test('http error response throws request exception', function () { +test('http error response throws request exception', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::response([ 'error' => [ @@ -32,7 +32,7 @@ ); })->throws(RequestException::class); -test('rate limit response throws rate limited exception', function () { +test('rate limit response throws rate limited exception', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::response([ 'error' => [ @@ -48,7 +48,7 @@ ); })->throws(RateLimitedException::class); -test('overloaded response throws provider overloaded exception', function () { +test('overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::response([ 'error' => [ @@ -64,7 +64,7 @@ ); })->throws(ProviderOverloadedException::class); -test('error in 200 response throws ai exception', function () { +test('error in 200 response throws ai exception', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::response([ 'error' => [ diff --git a/tests/Feature/Providers/AzureOpenAi/FileGatewayTest.php b/tests/Feature/Providers/AzureOpenAi/FileGatewayTest.php index 416cdb814..0459e165c 100644 --- a/tests/Feature/Providers/AzureOpenAi/FileGatewayTest.php +++ b/tests/Feature/Providers/AzureOpenAi/FileGatewayTest.php @@ -5,7 +5,7 @@ use Laravel\Ai\Enums\Lab; use Laravel\Ai\Files\Document; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.azure' => [ ...config('ai.providers.azure'), 'key' => 'test-key', @@ -13,7 +13,7 @@ ]]); }); -test('put file uploads to the v1 endpoint with the assistants purpose', function () { +test('put file uploads to the v1 endpoint with the assistants purpose', function (): void { Http::fake([ 'test-resource.openai.azure.com/*' => Http::response(['id' => 'file-uploaded123']), ]); @@ -24,20 +24,20 @@ expect($response->id)->toBe('file-uploaded123'); - Http::assertSent(fn (Request $request) => $request->method() === 'POST' + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->url() === 'https://test-resource.openai.azure.com/openai/v1/files' && str_contains($request->header('Content-Type')[0] ?? '', 'multipart/form-data') - && collect($request->data())->contains(fn ($field) => ($field['name'] ?? null) === 'purpose' && ($field['contents'] ?? null) === 'assistants') + && collect($request->data())->contains(fn ($field): bool => ($field['name'] ?? null) === 'purpose' && ($field['contents'] ?? null) === 'assistants') && $request->hasHeader('api-key', 'test-key')); }); -test('provider options are resolved with the azure key, not openai', function () { +test('provider options are resolved with the azure key, not openai', function (): void { Http::fake([ 'test-resource.openai.azure.com/*' => Http::response(['id' => 'file-uploaded123']), ]); Document::fromString('Hello, World!', 'text/plain')->as('hello.txt') - ->withProviderOptions(fn (Lab $provider) => match ($provider) { + ->withProviderOptions(fn (Lab $provider): array => match ($provider) { Lab::Azure => ['purpose' => 'batch'], Lab::OpenAI => ['purpose' => 'vision'], default => [], diff --git a/tests/Feature/Providers/AzureOpenAi/ImageGenerationTest.php b/tests/Feature/Providers/AzureOpenAi/ImageGenerationTest.php index 1c9ba0c9b..5a35dddb5 100644 --- a/tests/Feature/Providers/AzureOpenAi/ImageGenerationTest.php +++ b/tests/Feature/Providers/AzureOpenAi/ImageGenerationTest.php @@ -9,7 +9,7 @@ use Laravel\Ai\Files\LocalImage; use Laravel\Ai\Image; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.azure' => [ ...config('ai.providers.azure'), 'key' => 'test-key', @@ -26,14 +26,14 @@ function fakeAzureImageResponse(): PromiseInterface ]); } -test('image request uses correct deployment and url', function () { +test('image request uses correct deployment and url', function (): void { Http::fake([ '*' => fakeAzureImageResponse(), ]); Image::of('A red apple')->generate(provider: 'azure', model: 'gpt-image-1'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $request->url() === 'https://test-resource.openai.azure.com/openai/v1/images/generations' @@ -41,77 +41,77 @@ function fakeAzureImageResponse(): PromiseInterface }); }); -test('image request does not include quality when not specified', function () { +test('image request does not include quality when not specified', function (): void { Http::fake([ '*' => fakeAzureImageResponse(), ]); Image::of('A red apple')->generate(provider: 'azure', model: 'gpt-image-1'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('quality', $body); }); }); -test('image request includes quality when explicitly specified', function () { +test('image request includes quality when explicitly specified', function (): void { Http::fake([ '*' => fakeAzureImageResponse(), ]); Image::of('A red apple')->quality('high')->generate(provider: 'azure', model: 'gpt-image-1'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['quality'] === 'high'; }); }); -test('image request includes size when specified', function () { +test('image request includes size when specified', function (): void { Http::fake([ '*' => fakeAzureImageResponse(), ]); Image::of('A red apple')->square()->generate(provider: 'azure', model: 'gpt-image-1'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['size'] === '1024x1024'; }); }); -test('image request does not include size when not specified', function () { +test('image request does not include size when not specified', function (): void { Http::fake([ '*' => fakeAzureImageResponse(), ]); Image::of('A red apple')->generate(provider: 'azure', model: 'gpt-image-1'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('size', $body); }); }); -test('image request always includes moderation low', function () { +test('image request always includes moderation low', function (): void { Http::fake([ '*' => fakeAzureImageResponse(), ]); Image::of('A red apple')->generate(provider: 'azure', model: 'gpt-image-1'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ($body['moderation'] ?? null) === 'low'; }); }); -test('image generation throws when attachments are passed', function () { +test('image generation throws when attachments are passed', function (): void { Http::fake([ '*' => fakeAzureImageResponse(), ]); @@ -121,21 +121,21 @@ function fakeAzureImageResponse(): PromiseInterface ->generate(provider: 'azure', model: 'gpt-image-1'); })->throws(LogicException::class, 'Azure OpenAI does not support image editing.'); -test('image generation request omits response_format', function () { +test('image generation request omits response_format', function (): void { Http::fake([ '*' => fakeAzureImageResponse(), ]); Image::of('A red apple')->generate(provider: 'azure', model: 'gpt-image-1'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('response_format', $body); }); }); -test('image response includes usage tokens', function () { +test('image response includes usage tokens', function (): void { Http::fake([ '*' => Http::response([ 'data' => [[ @@ -158,7 +158,7 @@ function fakeAzureImageResponse(): PromiseInterface ->and($response->usage->completionTokens)->toBe(1024); }); -test('image response subtracts cached tokens from prompt tokens', function () { +test('image response subtracts cached tokens from prompt tokens', function (): void { Http::fake([ '*' => Http::response([ 'data' => [[ @@ -182,7 +182,7 @@ function fakeAzureImageResponse(): PromiseInterface ->and($response->usage->completionTokens)->toBe(1024); }); -test('image response defaults to zero usage when not returned', function () { +test('image response defaults to zero usage when not returned', function (): void { Http::fake([ '*' => fakeAzureImageResponse(), ]); @@ -193,7 +193,7 @@ function fakeAzureImageResponse(): PromiseInterface ->and($response->usage->completionTokens)->toBe(0); }); -test('default image model falls back to gpt-image-1', function () { +test('default image model falls back to gpt-image-1', function (): void { config(['ai.providers.azure.image_deployment' => null]); Http::fake([ @@ -202,14 +202,14 @@ function fakeAzureImageResponse(): PromiseInterface Image::of('A red apple')->generate(provider: 'azure'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'gpt-image-1'; }); }); -test('image rate limit response throws rate limited exception', function () { +test('image rate limit response throws rate limited exception', function (): void { Http::fake([ 'test-resource.openai.azure.com/*' => Http::response([ 'error' => [ @@ -222,7 +222,7 @@ function fakeAzureImageResponse(): PromiseInterface Image::of('A red apple')->generate(provider: 'azure', model: 'gpt-image-1'); })->throws(RateLimitedException::class); -test('image overloaded response throws provider overloaded exception', function () { +test('image overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'test-resource.openai.azure.com/*' => Http::response([ 'error' => [ @@ -235,7 +235,7 @@ function fakeAzureImageResponse(): PromiseInterface Image::of('A red apple')->generate(provider: 'azure', model: 'gpt-image-1'); })->throws(ProviderOverloadedException::class); -test('image http error response throws request exception', function () { +test('image http error response throws request exception', function (): void { Http::fake([ 'test-resource.openai.azure.com/*' => Http::response([ 'error' => [ diff --git a/tests/Feature/Providers/AzureOpenAi/MessageMappingTest.php b/tests/Feature/Providers/AzureOpenAi/MessageMappingTest.php index cf30e73c0..9500c9d6d 100644 --- a/tests/Feature/Providers/AzureOpenAi/MessageMappingTest.php +++ b/tests/Feature/Providers/AzureOpenAi/MessageMappingTest.php @@ -10,7 +10,7 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.azure' => [ ...config('ai.providers.azure'), 'key' => 'test-key', @@ -19,7 +19,7 @@ ]]); }); -test('user message maps to azure format', function () { +test('user message maps to azure format', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => fakeAzureResponse(), ]); @@ -29,7 +29,7 @@ provider: 'azure', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $input = $body['input']; $userMessage = collect($input)->firstWhere('role', 'user'); @@ -40,7 +40,7 @@ }); }); -test('tool result follow up uses previous response id', function () { +test('tool result follow up uses previous response id', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::sequence([ fakeOpenAiToolCallResponse('resp_azure_tool_123', 'gpt-4o'), @@ -57,7 +57,7 @@ expect($recorded)->toHaveCount(2); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); expect($followUpBody)->toHaveKey('previous_response_id') ->and($followUpBody['previous_response_id'])->toBe('resp_azure_tool_123'); @@ -75,7 +75,7 @@ expect($hasFunctionCallOutput)->toBeTrue(); }); -test('azure store false enables stateless inline conversation', function () { +test('azure store false enables stateless inline conversation', function (): void { config(['ai.providers.azure' => [ ...config('ai.providers.azure'), 'store' => false, @@ -91,15 +91,15 @@ (new ToolUsingAgent(fixed: true))->prompt('Generate a number', provider: 'azure'); $recorded = Http::recorded(); - $initialBody = json_decode($recorded[0][0]->body(), true); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $initialBody = json_decode((string) $recorded[0][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); expect($initialBody['store'] ?? null)->toBeFalse() ->and($followUpBody)->not->toHaveKey('previous_response_id') ->and($followUpBody['store'] ?? null)->toBeFalse(); }); -test('image attachment maps to input_image content block', function () { +test('image attachment maps to input_image content block', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => fakeAzureResponse('I see an image'), ]); @@ -112,7 +112,7 @@ provider: 'azure', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['input'])->firstWhere('role', 'user'); $content = $userMessage['content']; @@ -120,18 +120,18 @@ $imageBlock = collect($content)->firstWhere('type', 'input_image'); return $imageBlock !== null - && str_contains($imageBlock['image_url'], 'image/png') - && str_contains($imageBlock['image_url'], base64_encode('fake-image-data')); + && str_contains((string) $imageBlock['image_url'], 'image/png') + && str_contains((string) $imageBlock['image_url'], base64_encode('fake-image-data')); }); }); -test('attachment provider options closure receives the azure provider', function () { +test('attachment provider options closure receives the azure provider', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => fakeAzureResponse('I see an image'), ]); $image = (new Base64Image(base64_encode('fake-image-data'), 'image/png')) - ->withProviderOptions(fn (Lab $provider) => match ($provider) { + ->withProviderOptions(fn (Lab $provider): array => match ($provider) { Lab::Azure => ['detail' => 'low'], default => ['detail' => 'high'], }); @@ -142,7 +142,7 @@ provider: 'azure', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['input'])->firstWhere('role', 'user'); $imageBlock = collect($userMessage['content'])->firstWhere('type', 'input_image'); @@ -152,7 +152,7 @@ }); }); -test('document attachment maps to input_file content block', function () { +test('document attachment maps to input_file content block', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => fakeAzureResponse('I see a PDF'), ]); @@ -165,7 +165,7 @@ provider: 'azure', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['input'])->firstWhere('role', 'user'); $content = $userMessage['content']; @@ -173,6 +173,6 @@ $fileBlock = collect($content)->firstWhere('type', 'input_file'); return $fileBlock !== null - && str_contains($fileBlock['file_data'], 'application/pdf'); + && str_contains((string) $fileBlock['file_data'], 'application/pdf'); }); }); diff --git a/tests/Feature/Providers/AzureOpenAi/ProviderOptionsTest.php b/tests/Feature/Providers/AzureOpenAi/ProviderOptionsTest.php index c7f10806f..f389d4a7f 100644 --- a/tests/Feature/Providers/AzureOpenAi/ProviderOptionsTest.php +++ b/tests/Feature/Providers/AzureOpenAi/ProviderOptionsTest.php @@ -8,7 +8,7 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.azure' => [ ...config('ai.providers.azure'), 'key' => 'test-key', @@ -17,14 +17,14 @@ ]]); }); -test('provider options are included in azure request body', function () { +test('provider options are included in azure request body', function (): void { Http::fake([ '*' => fakeAzureResponse('Hello'), ]); (new ProviderOptionsAgent)->prompt('Hello', provider: 'azure'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'frequency_penalty') === 0.5 @@ -32,14 +32,14 @@ }); }); -test('request body does not contain provider options when agent does not implement interface', function () { +test('request body does not contain provider options when agent does not implement interface', function (): void { Http::fake([ '*' => fakeAzureResponse('Hello'), ]); agent()->prompt('Hello', provider: 'azure'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('reasoning', $body) @@ -48,7 +48,7 @@ }); }); -test('provider options are persisted in tool call follow up requests', function () { +test('provider options are persisted in tool call follow up requests', function (): void { Http::fake([ '*' => Http::sequence([ fakeAzureProviderOptionsToolCallResponse(), @@ -58,11 +58,11 @@ (new ProviderOptionsWithToolsAgent)->prompt('Give me a number', provider: 'azure'); - $requests = Http::recorded(fn (Request $r) => true); + $requests = Http::recorded(fn (Request $r): true => true); expect(count($requests))->toBeGreaterThanOrEqual(2); - $followUpBody = json_decode($requests[1][0]->body(), true); + $followUpBody = json_decode((string) $requests[1][0]->body(), true); expect(data_get($followUpBody, 'frequency_penalty'))->toBe(0.5); }); diff --git a/tests/Feature/Providers/AzureOpenAi/RequestMappingTest.php b/tests/Feature/Providers/AzureOpenAi/RequestMappingTest.php index 99fb78bd4..d46de6cfa 100644 --- a/tests/Feature/Providers/AzureOpenAi/RequestMappingTest.php +++ b/tests/Feature/Providers/AzureOpenAi/RequestMappingTest.php @@ -9,7 +9,7 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.azure' => [ ...config('ai.providers.azure'), 'key' => 'test-key', @@ -18,41 +18,41 @@ ]]); }); -test('request includes model and input', function () { +test('request includes model and input', function (): void { Http::fake(['*' => fakeAzureResponse('Hello')]); agent()->prompt('Hi there', provider: 'azure', model: 'gpt-4o'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'gpt-4o' && is_array($body['input']) - && collect($body['input'])->contains(fn ($m) => $m['role'] === 'user' - && collect($m['content'])->contains(fn ($c) => ($c['text'] ?? '') === 'Hi there')); + && collect($body['input'])->contains(fn ($m): bool => $m['role'] === 'user' + && collect($m['content'])->contains(fn ($c): bool => ($c['text'] ?? '') === 'Hi there')); }); }); -test('system instructions are sent as system message in input', function () { +test('system instructions are sent as system message in input', function (): void { Http::fake(['*' => fakeAzureResponse('Hello')]); (new AssistantAgent)->prompt('Hello', provider: 'azure'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $systemMsg = collect($body['input'])->firstWhere('role', 'system'); return $systemMsg !== null - && str_contains($systemMsg['content'], 'helpful assistant'); + && str_contains((string) $systemMsg['content'], 'helpful assistant'); }); }); -test('temperature and max tokens are included when set via attributes', function () { +test('temperature and max tokens are included when set via attributes', function (): void { Http::fake(['*' => fakeAzureResponse('Hello')]); (new AttributeAgent)->prompt('Hello', provider: 'azure'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'temperature') === 0.7 @@ -60,12 +60,12 @@ }); }); -test('temperature and max tokens are excluded when not set', function () { +test('temperature and max tokens are excluded when not set', function (): void { Http::fake(['*' => fakeAzureResponse('Hello')]); agent()->prompt('Hello', provider: 'azure'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('temperature', $body) @@ -73,26 +73,26 @@ }); }); -test('tools include tool choice auto', function () { +test('tools include tool choice auto', function (): void { Http::fake(['*' => fakeAzureResponse('42')]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a number', provider: 'azure'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['tool_choice'] === 'auto' && is_array($body['tools']) - && count($body['tools']) > 0; + && $body['tools'] !== []; }); }); -test('request without tools excludes tool fields', function () { +test('request without tools excludes tool fields', function (): void { Http::fake(['*' => fakeAzureResponse('Hello')]); agent()->prompt('Hello', provider: 'azure'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('tools', $body) @@ -100,12 +100,12 @@ }); }); -test('structured output includes json schema text format', function () { +test('structured output includes json schema text format', function (): void { Http::fake(['*' => fakeAzureResponse('{"symbol": "Au"}')]); (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'azure'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $format = data_get($body, 'text.format'); @@ -116,19 +116,19 @@ }); }); -test('request without schema excludes text format', function () { +test('request without schema excludes text format', function (): void { Http::fake(['*' => fakeAzureResponse('Hello')]); agent()->prompt('Hello', provider: 'azure'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('text', $body); }); }); -test('streaming request includes stream flag', function () { +test('streaming request includes stream flag', function (): void { Http::fake(['*' => Http::response( body: "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-4o\",\"status\":\"in_progress\",\"output\":[]}}\n\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"Hi\",\"item_id\":\"msg_1\",\"output_index\":0,\"content_index\":0}\n\ndata: {\"type\":\"response.output_text.done\",\"text\":\"Hi\",\"item_id\":\"msg_1\",\"output_index\":0,\"content_index\":0}\n\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"model\":\"gpt-4o\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hi\"}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens_details\":{\"reasoning_tokens\":0}}}}\n\n", )]); @@ -139,34 +139,30 @@ // } - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['stream'] === true; }); }); -test('request sends api-key header authentication', function () { +test('request sends api-key header authentication', function (): void { Http::fake(['*' => fakeAzureResponse('Hello')]); agent()->prompt('Hello', provider: 'azure'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('api-key', 'test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('api-key', 'test-key')); }); -test('request does not include api-version query parameter', function () { +test('request does not include api-version query parameter', function (): void { Http::fake(['*' => fakeAzureResponse('Hello')]); agent()->prompt('Hello', provider: 'azure'); - Http::assertSent(function (Request $request) { - return ! str_contains($request->url(), 'api-version'); - }); + Http::assertSent(fn (Request $request): bool => ! str_contains($request->url(), 'api-version')); }); -test('response text is correctly parsed', function () { +test('response text is correctly parsed', function (): void { Http::fake(['*' => fakeAzureResponse('Laravel is great')]); $response = agent()->prompt('Tell me about Laravel', provider: 'azure'); @@ -175,7 +171,7 @@ ->and($response->meta->provider)->toBe('azure'); }); -test('response usage is correctly parsed', function () { +test('response usage is correctly parsed', function (): void { Http::fake(['*' => Http::response([ 'id' => 'resp_azure_123', 'status' => 'completed', @@ -197,7 +193,7 @@ ->and($response->usage->completionTokens)->toBe(5); }); -test('structured response is correctly parsed', function () { +test('structured response is correctly parsed', function (): void { Http::fake(['*' => fakeAzureResponse('{"symbol": "Au"}')]); $response = (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'azure'); diff --git a/tests/Feature/Providers/AzureOpenAi/StoreGatewayTest.php b/tests/Feature/Providers/AzureOpenAi/StoreGatewayTest.php index c30e559df..af79e16df 100644 --- a/tests/Feature/Providers/AzureOpenAi/StoreGatewayTest.php +++ b/tests/Feature/Providers/AzureOpenAi/StoreGatewayTest.php @@ -6,7 +6,7 @@ use Laravel\Ai\Providers\Tools\FileSearch; use Laravel\Ai\Stores; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.azure' => [ ...config('ai.providers.azure'), 'key' => 'test-key', @@ -28,38 +28,38 @@ function fakeAzureStoreResponse(string $id = 'vs-123', string $name = 'Test Stor ]; } -test('get store sends request to the v1 endpoint with the api-key header', function () { +test('get store sends request to the v1 endpoint with the api-key header', function (): void { Http::fake([ 'test-resource.openai.azure.com/*' => Http::response(fakeAzureStoreResponse()), ]); expect(Stores::get('vs-123', provider: 'azure')->id)->toBe('vs-123'); - Http::assertSent(fn (Request $request) => $request->method() === 'GET' + Http::assertSent(fn (Request $request): bool => $request->method() === 'GET' && $request->url() === 'https://test-resource.openai.azure.com/openai/v1/vector_stores/vs-123' && $request->hasHeader('api-key', 'test-key')); }); -test('create store sends request to the v1 endpoint with the api-key header', function () { +test('create store sends request to the v1 endpoint with the api-key header', function (): void { Http::fake([ 'test-resource.openai.azure.com/*' => Http::response(fakeAzureStoreResponse()), ]); expect(Stores::create('Test Store', provider: 'azure')->id)->toBe('vs-123'); - Http::assertSent(fn (Request $request) => $request->method() === 'POST' + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->url() === 'https://test-resource.openai.azure.com/openai/v1/vector_stores' && $request->hasHeader('api-key', 'test-key')); }); -test('file search metadata filters throw an exception', function () { +test('file search metadata filters throw an exception', function (): void { $search = new FileSearch(['vs-123'], where: ['company' => 'laravel']); expect(fn () => Ai::storeProvider('azure')->fileSearchToolOptions($search)) ->toThrow(InvalidArgumentException::class, 'Azure OpenAI does not support file search metadata filters.'); }); -test('file search without filters returns vector store ids', function () { +test('file search without filters returns vector store ids', function (): void { $search = new FileSearch(['vs-123']); expect(Ai::storeProvider('azure')->fileSearchToolOptions($search)) diff --git a/tests/Feature/Providers/AzureOpenAi/StreamingTest.php b/tests/Feature/Providers/AzureOpenAi/StreamingTest.php index da41807e7..2d8439dd5 100644 --- a/tests/Feature/Providers/AzureOpenAi/StreamingTest.php +++ b/tests/Feature/Providers/AzureOpenAi/StreamingTest.php @@ -11,7 +11,7 @@ use Laravel\Ai\Streaming\Events\ToolCall as ToolCallEvent; use Tests\Fixtures\Agents\ProviderOptionsWithToolsAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.azure' => [ ...config('ai.providers.azure'), 'key' => 'test-key', @@ -20,7 +20,7 @@ ]]); }); -test('streaming emits text events', function () { +test('streaming emits text events', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::response( body: $this->ssePayload([ @@ -45,7 +45,7 @@ ->and($events[count($events) - 1])->toBeInstanceOf(StreamEnd::class); }); -test('streaming handles tool calls', function () { +test('streaming handles tool calls', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::sequence([ Http::response( @@ -76,8 +76,8 @@ $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); - $toolCallEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolCallEvent)); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $toolCallEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolCallEvent)); + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($toolCallEvents)->not->toBeEmpty() ->and($toolCallEvents[0]->toolCall->name)->toBe('FixedNumberGenerator') @@ -87,7 +87,7 @@ ->and($streamEnd->usage->completionTokens)->toBe(15); }); -test('streaming error event stops stream', function () { +test('streaming error event stops stream', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::response( body: $this->ssePayload([ @@ -106,7 +106,7 @@ ->and($events[0]->message)->toBe('Rate limit exceeded'); }); -test('streaming captures usage from completed event', function () { +test('streaming captures usage from completed event', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::response( body: $this->ssePayload([ @@ -122,13 +122,13 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->usage->promptTokens)->toBe(42) ->and($streamEnd->usage->completionTokens)->toBe(10); }); -test('streaming finish reason maps correctly', function (array $output, $expected) { +test('streaming finish reason maps correctly', function (array $output, $expected): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::response( body: $this->ssePayload([ @@ -142,7 +142,7 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->reason)->toBe($expected->value); })->with([ diff --git a/tests/Feature/Providers/AzureOpenAi/ToolCallLoopTest.php b/tests/Feature/Providers/AzureOpenAi/ToolCallLoopTest.php index 813568d0e..98574fe86 100644 --- a/tests/Feature/Providers/AzureOpenAi/ToolCallLoopTest.php +++ b/tests/Feature/Providers/AzureOpenAi/ToolCallLoopTest.php @@ -5,7 +5,7 @@ use Tests\Fixtures\Agents\MultiStepToolAgent; use Tests\Fixtures\Agents\ToolUsingAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.azure' => [ ...config('ai.providers.azure'), 'key' => 'test-key', @@ -14,7 +14,7 @@ ]]); }); -test('tool calls trigger follow up request', function () { +test('tool calls trigger follow up request', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::sequence([ fakeUniqueAzureToolCallResponse(), @@ -31,7 +31,7 @@ expect($recorded)->toHaveCount(2); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); expect($followUpBody)->toHaveKey('previous_response_id'); @@ -46,7 +46,7 @@ expect($hasFunctionCallOutput)->toBeTrue(); }); -test('max steps limits tool call depth', function () { +test('max steps limits tool call depth', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::sequence([ fakeUniqueAzureToolCallResponse(), @@ -66,7 +66,7 @@ expect(count($recorded))->toBeLessThanOrEqual(3); }); -test('multi step tool loop returns accumulated response shape', function () { +test('multi step tool loop returns accumulated response shape', function (): void { Http::fake([ 'my-resource.cognitiveservices.azure.com/*' => Http::sequence([ fakeUniqueAzureToolCallResponse(), diff --git a/tests/Feature/Providers/AzureOpenAi/ToolMappingTest.php b/tests/Feature/Providers/AzureOpenAi/ToolMappingTest.php index d06cfe41d..f5f427266 100644 --- a/tests/Feature/Providers/AzureOpenAi/ToolMappingTest.php +++ b/tests/Feature/Providers/AzureOpenAi/ToolMappingTest.php @@ -8,7 +8,7 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.azure' => [ ...config('ai.providers.azure'), 'key' => 'test-key', @@ -17,14 +17,14 @@ ]]); }); -test('tool with parameters includes schema without strict mode', function () { +test('tool with parameters includes schema without strict mode', function (): void { Http::fake([ '*' => fakeAzureResponse('42'), ]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a random number', provider: 'azure'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); @@ -38,12 +38,12 @@ }); }); -test('tool with a name() method emits the declared name', function () { +test('tool with a name() method emits the declared name', function (): void { Http::fake(['*' => fakeAzureResponse('ok')]); agent(tools: [new NamedTool('my_custom_tool')])->prompt('Hi', provider: 'azure'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $names = collect(data_get($body, 'tools'))->pluck('name')->all(); @@ -51,14 +51,14 @@ }); }); -test('tool with empty schema omits parameters key', function () { +test('tool with empty schema omits parameters key', function (): void { Http::fake([ '*' => fakeAzureResponse('72019'), ]); agent(tools: [new FixedNumberGenerator])->prompt('Give me a random number', provider: 'azure'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); diff --git a/tests/Feature/Providers/Bedrock/BedrockHelpers.php b/tests/Feature/Providers/Bedrock/BedrockHelpers.php index 5fe3e0650..250a680e0 100644 --- a/tests/Feature/Providers/Bedrock/BedrockHelpers.php +++ b/tests/Feature/Providers/Bedrock/BedrockHelpers.php @@ -26,7 +26,7 @@ protected function fakeBedrockStream(array $events): BedrockRuntimeClient protected function fakeBedrockStreamSequence(array $eventLists): BedrockRuntimeClient { return $this->bedrockClient(new MockHandler(array_map( - fn (array $events) => new Result(['stream' => $events]), + fn (array $events): Result => new Result(['stream' => $events]), $eventLists, ))); } @@ -34,7 +34,7 @@ protected function fakeBedrockStreamSequence(array $eventLists): BedrockRuntimeC protected function fakeBedrockConverseSequence(array $results): BedrockRuntimeClient { return $this->bedrockClient(new MockHandler(array_map( - fn (array $result) => new Result($result), + fn (array $result): Result => new Result($result), $results, ))); } @@ -82,7 +82,7 @@ protected function contentBlockStart(int $index, array $start = []): array { $payload = ['contentBlockIndex' => $index]; - if ($start) { + if ($start !== []) { $payload['start'] = $start; } diff --git a/tests/Feature/Providers/Bedrock/ReasoningTest.php b/tests/Feature/Providers/Bedrock/ReasoningTest.php index b3e680dc8..975868a47 100644 --- a/tests/Feature/Providers/Bedrock/ReasoningTest.php +++ b/tests/Feature/Providers/Bedrock/ReasoningTest.php @@ -3,8 +3,8 @@ use Laravel\Ai\Gateway\TextGenerationLoop; use Laravel\Ai\Messages\AssistantMessage; -describe('reasoning capture', function () { - test('captures reasoning content into providerContentBlocks', function () { +describe('reasoning capture', function (): void { + test('captures reasoning content into providerContentBlocks', function (): void { $client = $this->fakeBedrockConverse([ 'output' => [ 'message' => [ @@ -26,7 +26,7 @@ null, ); - $assistant = $response->messages->first(fn ($m) => $m instanceof AssistantMessage); + $assistant = $response->messages->first(fn ($m): bool => $m instanceof AssistantMessage); expect($assistant->providerContentBlocks)->toEqual([ ['reasoningContent' => ['reasoningText' => ['text' => 'thinking...', 'signature' => 'sig-1']]], diff --git a/tests/Feature/Providers/Bedrock/StreamingTest.php b/tests/Feature/Providers/Bedrock/StreamingTest.php index 4246bb2f4..a40112b37 100644 --- a/tests/Feature/Providers/Bedrock/StreamingTest.php +++ b/tests/Feature/Providers/Bedrock/StreamingTest.php @@ -13,8 +13,8 @@ use Laravel\Ai\Streaming\Events\TextStart; use Tests\Fixtures\Tools\FixedNumberGenerator; -describe('text streaming', function () { - test('streaming handles reasoning and text blocks', function () { +describe('text streaming', function (): void { + test('streaming handles reasoning and text blocks', function (): void { $client = $this->fakeBedrockStream([ $this->contentBlockStart(0), $this->contentBlockDelta(0, ['reasoningContent' => ['text' => 'Let me think']]), @@ -47,7 +47,7 @@ ->and($events[9])->toBeInstanceOf(StreamEnd::class); }); - test('each text block in a stream gets a distinct message id', function () { + test('each text block in a stream gets a distinct message id', function (): void { $client = $this->fakeBedrockStream([ $this->contentBlockStart(0), $this->contentBlockDelta(0, ['text' => 'first']), @@ -65,13 +65,13 @@ preserve_keys: false, ); - $textStarts = array_values(array_filter($events, fn ($e) => $e instanceof TextStart)); + $textStarts = array_values(array_filter($events, fn ($e): bool => $e instanceof TextStart)); expect($textStarts)->toHaveCount(2) ->and($textStarts[0]->messageId)->not->toBe($textStarts[1]->messageId); }); - test('streaming round-trips reasoning block on follow-up tool step', function () { + test('streaming round-trips reasoning block on follow-up tool step', function (): void { $mock = new MockHandler([ new Result(['stream' => [ $this->contentBlockStart(0), @@ -295,7 +295,7 @@ ]); }); - test('streaming round-trips redacted reasoning block', function () { + test('streaming round-trips redacted reasoning block', function (): void { $mock = new MockHandler([ new Result(['stream' => [ $this->contentBlockStart(0), diff --git a/tests/Feature/Providers/Bedrock/ToolCallLoopTest.php b/tests/Feature/Providers/Bedrock/ToolCallLoopTest.php index 09f468e12..15d0ab0ce 100644 --- a/tests/Feature/Providers/Bedrock/ToolCallLoopTest.php +++ b/tests/Feature/Providers/Bedrock/ToolCallLoopTest.php @@ -6,6 +6,7 @@ use Laravel\Ai\Gateway\TextGenerationOptions; use Laravel\Ai\Messages\UserMessage; use Laravel\Ai\Responses\StructuredTextResponse; +use Laravel\Ai\Responses\TextResponse; use Laravel\Ai\Streaming\Events\StreamEnd; use Laravel\Ai\Streaming\Events\ToolResult as ToolResultEvent; use Tests\Fixtures\Tools\FixedNumberGenerator; @@ -30,8 +31,8 @@ function bedrockTextResponse(string $text): array ]; } -describe('tool call loop', function () { - test('multi step tool loop returns accumulated response shape', function () { +describe('tool call loop', function (): void { + test('multi step tool loop returns accumulated response shape', function (): void { $client = $this->fakeBedrockConverseSequence([ bedrockToolCallResponse('t1'), bedrockToolCallResponse('t2'), @@ -58,7 +59,7 @@ function bedrockTextResponse(string $text): array ->and($response->usage->completionTokens)->toBe(11); }); - test('max steps limits tool call depth', function () { + test('max steps limits tool call depth', function (): void { $client = $this->fakeBedrockConverseSequence([ bedrockToolCallResponse('t1'), bedrockToolCallResponse('t2'), @@ -80,7 +81,7 @@ function bedrockTextResponse(string $text): array expect($response->steps)->toHaveCount(2); }); - test('unknown tool call throws NoSuchToolException', function () { + test('unknown tool call throws NoSuchToolException', function (): void { $client = $this->fakeBedrockConverse([ 'output' => ['message' => ['content' => [ ['toolUse' => ['toolUseId' => 't1', 'name' => 'NonExistentTool', 'input' => []]], @@ -91,7 +92,7 @@ function bedrockTextResponse(string $text): array $gateway = $this->gatewayWithClient($client); - expect(fn () => (new TextGenerationLoop($gateway))->generate( + expect(fn (): TextResponse => (new TextGenerationLoop($gateway))->generate( $this->bedrockProvider(), 'anthropic.claude-opus-4-7-v1:0', null, @@ -99,7 +100,7 @@ function bedrockTextResponse(string $text): array ))->toThrow(NoSuchToolException::class); }); - test('structured output is parsed from the synthetic tool call', function () { + test('structured output is parsed from the synthetic tool call', function (): void { $client = $this->fakeBedrockConverse([ 'output' => ['message' => ['content' => [ ['toolUse' => ['toolUseId' => 's1', 'name' => 'structured_output', 'input' => ['symbol' => 'Fe']]], @@ -124,7 +125,7 @@ function bedrockTextResponse(string $text): array ->and($response->usage->completionTokens)->toBe(4); }); - test('streaming tool loop emits a single stream end with accumulated usage', function () { + test('streaming tool loop emits a single stream end with accumulated usage', function (): void { $client = $this->fakeBedrockStreamSequence([ [ $this->contentBlockStart(0, ['toolUse' => ['toolUseId' => 't1', 'name' => 'FixedNumberGenerator']]), @@ -155,8 +156,8 @@ function bedrockTextResponse(string $text): array preserve_keys: false, ); - $streamEnds = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd)); - $toolResults = array_values(array_filter($events, fn ($e) => $e instanceof ToolResultEvent)); + $streamEnds = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd)); + $toolResults = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolResultEvent)); expect($streamEnds)->toHaveCount(1) ->and($streamEnds[0]->reason)->toBe('stop') diff --git a/tests/Feature/Providers/Cohere/BaseUrlTest.php b/tests/Feature/Providers/Cohere/BaseUrlTest.php index 6e1d21d31..ec4a995d3 100644 --- a/tests/Feature/Providers/Cohere/BaseUrlTest.php +++ b/tests/Feature/Providers/Cohere/BaseUrlTest.php @@ -22,7 +22,7 @@ function fakeCohereBaseUrlRerankingResponse(): PromiseInterface ]); } -test('cohere embedding requests use the configured base url', function () { +test('cohere embedding requests use the configured base url', function (): void { config(['ai.providers.cohere' => [ ...config('ai.providers.cohere'), 'key' => 'test-key', @@ -33,10 +33,10 @@ function fakeCohereBaseUrlRerankingResponse(): PromiseInterface Embeddings::for(['Hello'])->generate(provider: 'cohere', model: 'embed-v4.0'); - Http::assertSent(fn (Request $r) => $r->url() === 'http://localhost:8080/v2/embed'); + Http::assertSent(fn (Request $r): bool => $r->url() === 'http://localhost:8080/v2/embed'); }); -test('cohere reranking requests use the configured base url', function () { +test('cohere reranking requests use the configured base url', function (): void { config(['ai.providers.cohere' => [ ...config('ai.providers.cohere'), 'key' => 'test-key', @@ -47,10 +47,10 @@ function fakeCohereBaseUrlRerankingResponse(): PromiseInterface Reranking::of(['doc1'])->rerank('What is AI?', provider: 'cohere', model: 'rerank-v3.5'); - Http::assertSent(fn (Request $r) => $r->url() === 'http://localhost:8080/v2/rerank'); + Http::assertSent(fn (Request $r): bool => $r->url() === 'http://localhost:8080/v2/rerank'); }); -test('cohere requests fall back to the default base url', function () { +test('cohere requests fall back to the default base url', function (): void { config(['ai.providers.cohere' => [ ...config('ai.providers.cohere'), 'key' => 'test-key', @@ -60,5 +60,5 @@ function fakeCohereBaseUrlRerankingResponse(): PromiseInterface Embeddings::for(['Hello'])->generate(provider: 'cohere', model: 'embed-v4.0'); - Http::assertSent(fn (Request $r) => $r->url() === 'https://api.cohere.com/v2/embed'); + Http::assertSent(fn (Request $r): bool => $r->url() === 'https://api.cohere.com/v2/embed'); }); diff --git a/tests/Feature/Providers/Cohere/EmbeddingTest.php b/tests/Feature/Providers/Cohere/EmbeddingTest.php index ef2052eca..007e7c152 100644 --- a/tests/Feature/Providers/Cohere/EmbeddingTest.php +++ b/tests/Feature/Providers/Cohere/EmbeddingTest.php @@ -5,19 +5,19 @@ use Illuminate\Support\Facades\Http; use Laravel\Ai\Embeddings; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.cohere' => [ ...config('ai.providers.cohere'), 'key' => 'test-key', ]]); }); -test('embeddings request includes model, texts, input_type, and embedding_types', function () { +test('embeddings request includes model, texts, input_type, and embedding_types', function (): void { Http::fake(['*' => fakeCohereEmbeddingsResponse()]); Embeddings::for(['Hello world'])->generate(provider: 'cohere', model: 'embed-v4.0'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'embed-v4.0' @@ -28,7 +28,7 @@ }); }); -test('embeddings response is correctly parsed', function () { +test('embeddings response is correctly parsed', function (): void { Http::fake(['*' => fakeCohereEmbeddingsResponse()]); $response = Embeddings::for(['Hello world'])->generate(provider: 'cohere', model: 'embed-v4.0'); @@ -40,7 +40,7 @@ ->and($response->meta->model)->toBe('embed-v4.0'); }); -test('embeddings request sends bearer token', function () { +test('embeddings request sends bearer token', function (): void { Http::fake(['*' => fakeCohereEmbeddingsResponse()]); Embeddings::for(['Hello'])->generate(provider: 'cohere', model: 'embed-v4.0'); @@ -48,15 +48,15 @@ Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('embeddings use default model when none specified', function () { +test('embeddings use default model when none specified', function (): void { Http::fake(['*' => fakeCohereEmbeddingsResponse()]); Embeddings::for(['Hello'])->generate(provider: 'cohere'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['model'] === 'embed-v4.0'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['model'] === 'embed-v4.0'); }); -test('multiple inputs return multiple embeddings', function () { +test('multiple inputs return multiple embeddings', function (): void { Http::fake(['*' => Http::response([ 'embeddings' => ['float' => [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]], 'meta' => ['billed_units' => ['input_tokens' => 20]], @@ -68,14 +68,14 @@ ->and($response->embeddings[1])->toBe([0.4, 0.5, 0.6]); }); -test('embeddings request includes provider options and lets them override default input_type', function () { +test('embeddings request includes provider options and lets them override default input_type', function (): void { Http::fake(['*' => fakeCohereEmbeddingsResponse()]); Embeddings::for(['Hello']) ->withProviderOptions(['input_type' => 'search_query', 'truncate' => 'END']) ->generate(provider: 'cohere', model: 'embed-v4.0'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['input_type'] === 'search_query' @@ -85,21 +85,21 @@ }); }); -test('provider options cannot override framework controlled keys', function () { +test('provider options cannot override framework controlled keys', function (): void { Http::fake(['*' => fakeCohereEmbeddingsResponse()]); Embeddings::for(['Hello']) ->withProviderOptions(['model' => 'hijacked', 'texts' => ['hijacked']]) ->generate(provider: 'cohere', model: 'embed-v4.0'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'embed-v4.0' && $body['texts'] === ['Hello']; }); }); -test('embeddings throw when the API returns an error', function () { +test('embeddings throw when the API returns an error', function (): void { Http::fake(['*' => Http::response(['message' => 'unauthorized'], 401)]); Embeddings::for(['Hello'])->generate(provider: 'cohere', model: 'embed-v4.0'); diff --git a/tests/Feature/Providers/Cohere/ErrorHandlingTest.php b/tests/Feature/Providers/Cohere/ErrorHandlingTest.php index c0be2246c..e16daaddc 100644 --- a/tests/Feature/Providers/Cohere/ErrorHandlingTest.php +++ b/tests/Feature/Providers/Cohere/ErrorHandlingTest.php @@ -7,14 +7,14 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Laravel\Ai\Reranking; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.cohere' => [ ...config('ai.providers.cohere'), 'key' => 'test-key', ]]); }); -test('embeddings rate limit response throws rate limited exception', function () { +test('embeddings rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.cohere.com/*' => Http::response(['message' => 'Rate limit exceeded'], 429), ]); @@ -22,7 +22,7 @@ Embeddings::for(['Hello'])->generate(provider: 'cohere', model: 'embed-v4.0'); })->throws(RateLimitedException::class); -test('embeddings overloaded response throws provider overloaded exception', function () { +test('embeddings overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.cohere.com/*' => Http::response(['message' => 'Service overloaded'], 503), ]); @@ -30,7 +30,7 @@ Embeddings::for(['Hello'])->generate(provider: 'cohere', model: 'embed-v4.0'); })->throws(ProviderOverloadedException::class); -test('embeddings http error response throws request exception', function () { +test('embeddings http error response throws request exception', function (): void { Http::fake([ 'api.cohere.com/*' => Http::response(['message' => 'Unauthorized'], 401), ]); @@ -38,7 +38,7 @@ Embeddings::for(['Hello'])->generate(provider: 'cohere', model: 'embed-v4.0'); })->throws(RequestException::class); -test('reranking rate limit response throws rate limited exception', function () { +test('reranking rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.cohere.com/*' => Http::response(['message' => 'Rate limit exceeded'], 429), ]); @@ -46,7 +46,7 @@ Reranking::of(['doc1'])->rerank('What is AI?', provider: 'cohere', model: 'rerank-v3.5'); })->throws(RateLimitedException::class); -test('reranking overloaded response throws provider overloaded exception', function () { +test('reranking overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.cohere.com/*' => Http::response(['message' => 'Service overloaded'], 503), ]); @@ -54,7 +54,7 @@ Reranking::of(['doc1'])->rerank('What is AI?', provider: 'cohere', model: 'rerank-v3.5'); })->throws(ProviderOverloadedException::class); -test('reranking http error response throws request exception', function () { +test('reranking http error response throws request exception', function (): void { Http::fake([ 'api.cohere.com/*' => Http::response(['message' => 'Unauthorized'], 401), ]); diff --git a/tests/Feature/Providers/Cohere/RerankingTest.php b/tests/Feature/Providers/Cohere/RerankingTest.php index 203f65e6d..67a448e45 100644 --- a/tests/Feature/Providers/Cohere/RerankingTest.php +++ b/tests/Feature/Providers/Cohere/RerankingTest.php @@ -8,20 +8,20 @@ use Laravel\Ai\Reranking; use Laravel\Ai\Responses\Data\RankedDocument; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.cohere' => [ ...config('ai.providers.cohere'), 'key' => 'test-key', ]]); }); -test('reranking request includes model, query, and documents', function () { +test('reranking request includes model, query, and documents', function (): void { Http::fake(['*' => fakeCohereRerankingResponse()]); Reranking::of(['Laravel is a PHP framework', 'React is a JS library']) ->rerank('What is Laravel?', provider: 'cohere', model: 'rerank-v3.5'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'rerank-v3.5' @@ -32,17 +32,17 @@ }); }); -test('reranking request includes top_n when limit set', function () { +test('reranking request includes top_n when limit set', function (): void { Http::fake(['*' => fakeCohereRerankingResponse()]); Reranking::of(['Doc A', 'Doc B', 'Doc C']) ->limit(2) ->rerank('query', provider: 'cohere', model: 'rerank-v3.5'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['top_n'] === 2); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['top_n'] === 2); }); -test('reranking response is correctly parsed into RankedDocuments', function () { +test('reranking response is correctly parsed into RankedDocuments', function (): void { Http::fake(['*' => fakeCohereRerankingResponse()]); $response = Reranking::of(['Laravel is a PHP framework', 'React is a JS library']) @@ -57,7 +57,7 @@ ->and($response->meta->model)->toBe('rerank-v3.5'); }); -test('reranking request sends bearer token', function () { +test('reranking request sends bearer token', function (): void { Http::fake(['*' => fakeCohereRerankingResponse()]); Reranking::of(['Doc A', 'Doc B'])->rerank('query', provider: 'cohere', model: 'rerank-v3.5'); @@ -65,15 +65,15 @@ Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('reranking uses default model when none specified', function () { +test('reranking uses default model when none specified', function (): void { Http::fake(['*' => fakeCohereRerankingResponse()]); Reranking::of(['Doc A', 'Doc B'])->rerank('query', provider: 'cohere'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['model'] === 'rerank-v3.5'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['model'] === 'rerank-v3.5'); }); -test('reranking maps documents by index when results are returned out of order', function () { +test('reranking maps documents by index when results are returned out of order', function (): void { Http::fake(['*' => Http::response([ 'results' => [ ['index' => 2, 'relevance_score' => 0.91], @@ -94,19 +94,19 @@ ->and($ranked[1]->document)->toBe('Doc A'); }); -test('reranking throws when the API returns an error', function () { +test('reranking throws when the API returns an error', function (): void { Http::fake(['*' => Http::response(['message' => 'unauthorized'], 401)]); Reranking::of(['Doc A', 'Doc B'])->rerank('query', provider: 'cohere', model: 'rerank-v3.5'); })->throws(RequestException::class); -test('reranking rate limit response throws rate limited exception', function () { +test('reranking rate limit response throws rate limited exception', function (): void { Http::fake(['api.cohere.com/*' => Http::response(['message' => 'rate limit exceeded'], 429)]); Reranking::of(['Doc A', 'Doc B'])->rerank('query', provider: 'cohere', model: 'rerank-v3.5'); })->throws(RateLimitedException::class); -test('reranking overloaded response throws provider overloaded exception', function () { +test('reranking overloaded response throws provider overloaded exception', function (): void { Http::fake(['api.cohere.com/*' => Http::response(['message' => 'service unavailable'], 503)]); Reranking::of(['Doc A', 'Doc B'])->rerank('query', provider: 'cohere', model: 'rerank-v3.5'); diff --git a/tests/Feature/Providers/DeepSeek/AgentFakeTest.php b/tests/Feature/Providers/DeepSeek/AgentFakeTest.php index faa3bbade..a263eeb46 100644 --- a/tests/Feature/Providers/DeepSeek/AgentFakeTest.php +++ b/tests/Feature/Providers/DeepSeek/AgentFakeTest.php @@ -2,7 +2,7 @@ use Tests\Fixtures\Agents\DeepSeekAgent; -test('deepseek agent can be faked', function () { +test('deepseek agent can be faked', function (): void { DeepSeekAgent::fake(['Test response']); $response = (new DeepSeekAgent)->prompt('Hello'); @@ -10,15 +10,15 @@ expect($response->text)->toBe('Test response'); }); -test('deepseek agent fake with closure', function () { - DeepSeekAgent::fake(fn (string $prompt) => "Echo: {$prompt}"); +test('deepseek agent fake with closure', function (): void { + DeepSeekAgent::fake(fn (string $prompt): string => "Echo: {$prompt}"); $response = (new DeepSeekAgent)->prompt('Hello world'); expect($response->text)->toBe('Echo: Hello world'); }); -test('deepseek agent fake with no predefined responses', function () { +test('deepseek agent fake with no predefined responses', function (): void { DeepSeekAgent::fake(); $response = (new DeepSeekAgent)->prompt('Hello'); @@ -26,7 +26,7 @@ expect($response->text)->toBe('Fake response for prompt: Hello'); }); -test('deepseek agent fake records prompts', function () { +test('deepseek agent fake records prompts', function (): void { DeepSeekAgent::fake(); (new DeepSeekAgent)->prompt('Hello'); @@ -35,11 +35,11 @@ DeepSeekAgent::assertNotPrompted('Goodbye'); }); -test('deepseek agent stream can be faked', function () { +test('deepseek agent stream can be faked', function (): void { DeepSeekAgent::fake(['Streamed response']); $response = (new DeepSeekAgent)->stream('Hello'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->text)->toBe('Streamed response'); }); diff --git a/tests/Feature/Providers/DeepSeek/BaseUrlTest.php b/tests/Feature/Providers/DeepSeek/BaseUrlTest.php index b1bb37ded..a32541643 100644 --- a/tests/Feature/Providers/DeepSeek/BaseUrlTest.php +++ b/tests/Feature/Providers/DeepSeek/BaseUrlTest.php @@ -5,11 +5,11 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { $this->customUrl = 'http://localhost:1234/v1'; }); -test('deepseek text requests use the configured base url', function () { +test('deepseek text requests use the configured base url', function (): void { configureDeepSeekProvider($this->customUrl); Http::fake([ @@ -24,7 +24,7 @@ deepseekAssertRequestSent('POST', "{$this->customUrl}/chat/completions"); }); -test('deepseek requests fall back to the default base url', function () { +test('deepseek requests fall back to the default base url', function (): void { configureDeepSeekProvider(); Http::fake([ @@ -50,6 +50,6 @@ function configureDeepSeekProvider(?string $url = null): void function deepseekAssertRequestSent(string $method, string $url): void { - Http::assertSent(fn (Request $request) => $request->method() === $method + Http::assertSent(fn (Request $request): bool => $request->method() === $method && $request->url() === $url); } diff --git a/tests/Feature/Providers/DeepSeek/DeepSeekHelpers.php b/tests/Feature/Providers/DeepSeek/DeepSeekHelpers.php index e48a49a02..83cde5481 100644 --- a/tests/Feature/Providers/DeepSeek/DeepSeekHelpers.php +++ b/tests/Feature/Providers/DeepSeek/DeepSeekHelpers.php @@ -30,11 +30,7 @@ protected function ssePayload(array $events): string $lines = []; foreach ($events as $event) { - if ($event === '[DONE]') { - $lines[] = 'data: [DONE]'; - } else { - $lines[] = 'data: '.json_encode($event); - } + $lines[] = $event === '[DONE]' ? 'data: [DONE]' : 'data: '.json_encode($event); } return implode("\n\n", $lines)."\n\n"; @@ -130,7 +126,7 @@ protected function requestMessages(int $requestIndex = 0): array protected function findMessage(array $messages, string $role, ?string $has = null): ?array { return collect($messages)->first( - fn (array $m) => $m['role'] === $role && ($has === null || isset($m[$has])) + fn (array $m): bool => $m['role'] === $role && ($has === null || isset($m[$has])) ); } @@ -141,6 +137,6 @@ protected function findMessage(array $messages, string $role, ?string $has = nul */ protected function filterMessages(array $messages, string $role): array { - return collect($messages)->filter(fn (array $m) => $m['role'] === $role)->values()->all(); + return collect($messages)->filter(fn (array $m): bool => $m['role'] === $role)->values()->all(); } } diff --git a/tests/Feature/Providers/DeepSeek/ErrorHandlingTest.php b/tests/Feature/Providers/DeepSeek/ErrorHandlingTest.php index 7eca1714c..1dd5e8845 100644 --- a/tests/Feature/Providers/DeepSeek/ErrorHandlingTest.php +++ b/tests/Feature/Providers/DeepSeek/ErrorHandlingTest.php @@ -8,14 +8,14 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Tests\Fixtures\Agents\AssistantAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.deepseek' => [ ...config('ai.providers.deepseek'), 'key' => 'test-key', ]]); }); -test('http error response throws request exception', function () { +test('http error response throws request exception', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::response([ 'error' => [ @@ -31,7 +31,7 @@ ); })->throws(RequestException::class); -test('rate limit response throws rate limited exception', function () { +test('rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::response([ 'error' => [ @@ -47,7 +47,7 @@ ); })->throws(RateLimitedException::class); -test('overloaded response throws provider overloaded exception', function () { +test('overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::response([ 'error' => [ @@ -63,7 +63,7 @@ ); })->throws(ProviderOverloadedException::class); -test('402 response throws insufficient credits exception', function () { +test('402 response throws insufficient credits exception', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::response([ 'error' => [ @@ -81,7 +81,7 @@ ); })->throws(InsufficientCreditsException::class); -test('error in 200 response throws ai exception', function () { +test('error in 200 response throws ai exception', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::response([ 'error' => [ diff --git a/tests/Feature/Providers/DeepSeek/MessageMappingTest.php b/tests/Feature/Providers/DeepSeek/MessageMappingTest.php index 899ed1f43..c18fbfac4 100644 --- a/tests/Feature/Providers/DeepSeek/MessageMappingTest.php +++ b/tests/Feature/Providers/DeepSeek/MessageMappingTest.php @@ -9,14 +9,14 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.deepseek' => [ ...config('ai.providers.deepseek'), 'key' => 'test-key', ]]); }); -test('user message maps to deepseek format', function () { +test('user message maps to deepseek format', function (): void { Http::fake([ 'api.deepseek.com/*' => fakeDeepSeekResponse(), ]); @@ -26,7 +26,7 @@ provider: 'deepseek', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['messages'])->firstWhere('role', 'user'); @@ -35,7 +35,7 @@ }); }); -test('tool result follow up maps assistant and tool result messages', function () { +test('tool result follow up maps assistant and tool result messages', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::sequence([ fakeDeepSeekToolCallResponse(), @@ -52,7 +52,7 @@ expect($recorded)->toHaveCount(2); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); $followUpMessages = $followUpBody['messages']; $hasAssistantWithToolCalls = false; @@ -71,15 +71,15 @@ expect($hasAssistantWithToolCalls)->toBeTrue() ->and($hasToolResult)->toBeTrue(); - $assistantMsg = collect($followUpMessages)->last(fn ($m) => $m['role'] === 'assistant' && isset($m['tool_calls'])); - $toolMsg = collect($followUpMessages)->last(fn ($m) => $m['role'] === 'tool'); + $assistantMsg = collect($followUpMessages)->last(fn ($m): bool => $m['role'] === 'assistant' && isset($m['tool_calls'])); + $toolMsg = collect($followUpMessages)->last(fn ($m): bool => $m['role'] === 'tool'); expect($assistantMsg['tool_calls'][0]['function']['name'])->toBe('FixedNumberGenerator') ->and($toolMsg['tool_call_id'])->toBe($assistantMsg['tool_calls'][0]['id']) ->and($toolMsg['content'])->not->toBeEmpty(); }); -test('image attachment maps to image url content block', function () { +test('image attachment maps to image url content block', function (): void { Http::fake([ 'api.deepseek.com/*' => fakeDeepSeekResponse('I see an image'), ]); @@ -92,7 +92,7 @@ provider: 'deepseek', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['messages'])->firstWhere('role', 'user'); $content = $userMessage['content']; @@ -100,12 +100,12 @@ $imageBlock = collect($content)->firstWhere('type', 'image_url'); return $imageBlock !== null - && str_contains($imageBlock['image_url']['url'], 'image/png') - && str_contains($imageBlock['image_url']['url'], base64_encode('fake-image-data')); + && str_contains((string) $imageBlock['image_url']['url'], 'image/png') + && str_contains((string) $imageBlock['image_url']['url'], base64_encode('fake-image-data')); }); }); -test('document attachments throw exception', function () { +test('document attachments throw exception', function (): void { Http::fake([ 'api.deepseek.com/*' => fakeDeepSeekResponse(), ]); diff --git a/tests/Feature/Providers/DeepSeek/ProviderOptionsTest.php b/tests/Feature/Providers/DeepSeek/ProviderOptionsTest.php index 160757ec7..8540533c8 100644 --- a/tests/Feature/Providers/DeepSeek/ProviderOptionsTest.php +++ b/tests/Feature/Providers/DeepSeek/ProviderOptionsTest.php @@ -7,21 +7,21 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.deepseek' => [ ...config('ai.providers.deepseek'), 'key' => 'test-key', ]]); }); -test('provider options are included in deepseek request body', function () { +test('provider options are included in deepseek request body', function (): void { Http::fake([ '*' => fakeDeepSeekResponse('Hello'), ]); (new ProviderOptionsAgent)->prompt('Hello', provider: 'deepseek'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'frequency_penalty') === 0.5 @@ -29,14 +29,14 @@ }); }); -test('request body does not contain provider options when agent does not implement interface', function () { +test('request body does not contain provider options when agent does not implement interface', function (): void { Http::fake([ '*' => fakeDeepSeekResponse('Hello'), ]); agent()->prompt('Hello', provider: 'deepseek'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('frequency_penalty', $body) @@ -44,7 +44,7 @@ }); }); -test('provider options are persisted in tool call follow up requests', function () { +test('provider options are persisted in tool call follow up requests', function (): void { Http::fake([ '*' => Http::sequence([ fakeDeepSeekToolCallResponse(), @@ -54,11 +54,11 @@ (new ProviderOptionsWithToolsAgent)->prompt('Give me a number', provider: 'deepseek'); - $requests = Http::recorded(fn (Request $r) => true); + $requests = Http::recorded(fn (Request $r): true => true); expect($requests)->toHaveCount(2); - $followUpBody = json_decode($requests[1][0]->body(), true); + $followUpBody = json_decode((string) $requests[1][0]->body(), true); expect(data_get($followUpBody, 'frequency_penalty'))->toBe(0.5); }); diff --git a/tests/Feature/Providers/DeepSeek/ReasoningTest.php b/tests/Feature/Providers/DeepSeek/ReasoningTest.php index d987513af..2f4a3609b 100644 --- a/tests/Feature/Providers/DeepSeek/ReasoningTest.php +++ b/tests/Feature/Providers/DeepSeek/ReasoningTest.php @@ -19,14 +19,14 @@ uses(DeepSeekHelpers::class); -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.deepseek' => [ ...config('ai.providers.deepseek'), 'key' => 'test-key', ]]); }); -test('preserves reasoning content across tool-call loops', function () { +test('preserves reasoning content across tool-call loops', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::sequence([ Http::response([ @@ -66,7 +66,7 @@ ->and($assistantMsg['tool_calls'][0]['function']['name'])->toBe('FixedNumberGenerator'); }); -test('emits reasoning start, delta, and end events while streaming', function () { +test('emits reasoning start, delta, and end events while streaming', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::response( body: $this->ssePayload([ @@ -96,7 +96,7 @@ ->and($events[9])->toBeInstanceOf(StreamEnd::class); }); -test('preserves reasoning content across streaming tool-call loops', function () { +test('preserves reasoning content across streaming tool-call loops', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::sequence([ Http::response( @@ -131,7 +131,7 @@ ->and($assistantMsg['tool_calls'][0]['function']['name'])->toBe('FixedNumberGenerator'); }); -test('strips reasoning from deepseek-reasoner historical messages without tool calls', function () { +test('strips reasoning from deepseek-reasoner historical messages without tool calls', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::response([ 'id' => 'chatcmpl-reasoner-2', @@ -155,7 +155,7 @@ ->and($assistantMsg)->not->toHaveKey('tool_calls'); }); -test('defaults reasoning content to empty string when historical reasoning is empty', function () { +test('defaults reasoning content to empty string when historical reasoning is empty', function (): void { Http::fake(['api.deepseek.com/*' => fakeDeepSeekResponse('Here you go.')]); (new HistoricalToolCallWithEmptyReasoningAgent)->prompt('tell me more', provider: 'deepseek'); @@ -171,7 +171,7 @@ expect($this->filterMessages($messages, 'tool'))->toHaveCount(1); }); -test('defaults reasoning content to empty string when historical reasoning is missing', function () { +test('defaults reasoning content to empty string when historical reasoning is missing', function (): void { Http::fake(['api.deepseek.com/*' => fakeDeepSeekResponse('Sure, here is the info.')]); (new HistoricalToolCallWithoutReasoningAgent)->prompt('tell me more', provider: 'deepseek'); @@ -187,7 +187,7 @@ expect($this->filterMessages($messages, 'tool'))->toHaveCount(1); }); -test('preserves tool calls when historical reasoning content is present', function () { +test('preserves tool calls when historical reasoning content is present', function (): void { Http::fake(['api.deepseek.com/*' => fakeDeepSeekResponse('Here you go.')]); (new HistoricalToolCallWithReasoningAgent)->prompt('tell me more', provider: 'deepseek'); diff --git a/tests/Feature/Providers/DeepSeek/RequestMappingTest.php b/tests/Feature/Providers/DeepSeek/RequestMappingTest.php index b85a9dd2b..18028833d 100644 --- a/tests/Feature/Providers/DeepSeek/RequestMappingTest.php +++ b/tests/Feature/Providers/DeepSeek/RequestMappingTest.php @@ -12,47 +12,47 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.deepseek' => [ ...config('ai.providers.deepseek'), 'key' => 'test-key', ]]); }); -test('request includes model and messages', function () { +test('request includes model and messages', function (): void { Http::fake(['*' => fakeDeepSeekResponse('Hello')]); agent()->prompt('Hi there', provider: 'deepseek', model: 'deepseek-chat'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'deepseek-chat' && count($body['messages']) >= 1 - && collect($body['messages'])->contains(fn ($m) => $m['role'] === 'user' && $m['content'] === 'Hi there'); + && collect($body['messages'])->contains(fn ($m): bool => $m['role'] === 'user' && $m['content'] === 'Hi there'); }); }); -test('system instructions are sent as system message', function () { +test('system instructions are sent as system message', function (): void { Http::fake(['*' => fakeDeepSeekResponse('Hello')]); (new AssistantAgent)->prompt('Hello', provider: 'deepseek'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $systemMsg = collect($body['messages'])->firstWhere('role', 'system'); return $systemMsg !== null - && str_contains($systemMsg['content'], 'helpful assistant'); + && str_contains((string) $systemMsg['content'], 'helpful assistant'); }); }); -test('temperature and max tokens are included when set via attributes', function () { +test('temperature and max tokens are included when set via attributes', function (): void { Http::fake(['*' => fakeDeepSeekResponse('Hello')]); (new AttributeAgent)->prompt('Hello', provider: 'deepseek'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'temperature') === 0.7 @@ -60,12 +60,12 @@ }); }); -test('temperature and max tokens are excluded when not set', function () { +test('temperature and max tokens are excluded when not set', function (): void { Http::fake(['*' => fakeDeepSeekResponse('Hello')]); agent()->prompt('Hello', provider: 'deepseek'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('temperature', $body) @@ -73,26 +73,26 @@ }); }); -test('tools include tool choice auto', function () { +test('tools include tool choice auto', function (): void { Http::fake(['*' => fakeDeepSeekResponse('42')]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a number', provider: 'deepseek'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['tool_choice'] === 'auto' && is_array($body['tools']) - && count($body['tools']) > 0; + && $body['tools'] !== []; }); }); -test('request without tools excludes tool fields', function () { +test('request without tools excludes tool fields', function (): void { Http::fake(['*' => fakeDeepSeekResponse('Hello')]); agent()->prompt('Hello', provider: 'deepseek'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('tools', $body) @@ -100,83 +100,81 @@ }); }); -test('required tool choice forces the model to call a tool', function () { +test('required tool choice forces the model to call a tool', function (): void { Http::fake(['*' => fakeDeepSeekResponse('42')]); (new ToolChoiceAgent('required'))->prompt('Give me a number', provider: 'deepseek'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'required'); }); -test('required tool choice can be set via attribute', function () { +test('required tool choice can be set via attribute', function (): void { Http::fake(['*' => fakeDeepSeekResponse('42')]); (new AttributeToolChoiceAgent)->prompt('Give me a number', provider: 'deepseek'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'required'); }); -test('named tool choice forces a specific function', function () { +test('named tool choice forces a specific function', function (): void { Http::fake(['*' => fakeDeepSeekResponse('42')]); (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Give me a number', provider: 'deepseek'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['tool_choice'] === [ - 'type' => 'function', - 'function' => ['name' => 'custom_named_tool'], - ]; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === [ + 'type' => 'function', + 'function' => ['name' => 'custom_named_tool'], + ]); }); -test('none tool choice prevents tool calls', function () { +test('none tool choice prevents tool calls', function (): void { Http::fake(['*' => fakeDeepSeekResponse('Sure')]); (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'deepseek'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'none'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'none'); }); -test('structured output uses json object response format', function () { +test('structured output uses json object response format', function (): void { Http::fake(['*' => fakeDeepSeekResponse('{"symbol": "Au"}')]); (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'deepseek'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'response_format') === ['type' => 'json_object']; }); }); -test('structured output appends schema instructions to system message', function () { +test('structured output appends schema instructions to system message', function (): void { Http::fake(['*' => fakeDeepSeekResponse('{"symbol": "Au"}')]); (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'deepseek'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $systemMsg = collect($body['messages'])->firstWhere('role', 'system'); return $systemMsg !== null - && str_contains($systemMsg['content'], 'JSON object that strictly adheres') - && str_contains($systemMsg['content'], '"symbol"'); + && str_contains((string) $systemMsg['content'], 'JSON object that strictly adheres') + && str_contains((string) $systemMsg['content'], '"symbol"'); }); }); -test('request without schema excludes response format', function () { +test('request without schema excludes response format', function (): void { Http::fake(['*' => fakeDeepSeekResponse('Hello')]); agent()->prompt('Hello', provider: 'deepseek'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('response_format', $body); }); }); -test('streaming request includes stream options', function () { +test('streaming request includes stream options', function (): void { Http::fake(['*' => Http::response("data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hi\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1}}\n\ndata: [DONE]\n\n")]); $stream = agent()->stream('Hello', provider: 'deepseek'); @@ -185,7 +183,7 @@ // } - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['stream'] === true @@ -193,17 +191,15 @@ }); }); -test('request sends bearer token authorization', function () { +test('request sends bearer token authorization', function (): void { Http::fake(['*' => fakeDeepSeekResponse('Hello')]); agent()->prompt('Hello', provider: 'deepseek'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('response text is correctly parsed', function () { +test('response text is correctly parsed', function (): void { Http::fake(['*' => fakeDeepSeekResponse('Laravel is great')]); $response = agent()->prompt('Tell me about Laravel', provider: 'deepseek'); @@ -212,7 +208,7 @@ ->and($response->meta->provider)->toBe('deepseek'); }); -test('response usage is correctly parsed', function () { +test('response usage is correctly parsed', function (): void { Http::fake(['*' => Http::response([ 'id' => 'chatcmpl-123', 'object' => 'chat.completion', @@ -234,7 +230,7 @@ ->and($response->usage->completionTokens)->toBe(5); }); -test('response usage includes cache hit and reasoning tokens', function () { +test('response usage includes cache hit and reasoning tokens', function (): void { Http::fake(['*' => Http::response([ 'id' => 'chatcmpl-reasoner-1', 'object' => 'chat.completion', @@ -265,7 +261,7 @@ ->and($response->usage->reasoningTokens)->toBe(15); }); -test('reasoning content from deepseek-reasoner is ignored, only content surfaces', function () { +test('reasoning content from deepseek-reasoner is ignored, only content surfaces', function (): void { Http::fake(['*' => Http::response([ 'id' => 'chatcmpl-reasoner-1', 'object' => 'chat.completion', @@ -290,7 +286,7 @@ expect($response->text)->toBe('The answer is 4.'); }); -test('local image attachment without explicit mime type detects mime from file', function () { +test('local image attachment without explicit mime type detects mime from file', function (): void { Http::fake(['*' => fakeDeepSeekResponse('I see an image')]); agent('You are helpful.')->prompt( @@ -299,13 +295,13 @@ provider: 'deepseek', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMsg = collect($body['messages'])->firstWhere('role', 'user'); $imageBlock = collect($userMsg['content'])->firstWhere('type', 'image_url'); return $imageBlock !== null - && str_starts_with($imageBlock['image_url']['url'], 'data:image/png;base64,') - && ! str_contains($imageBlock['image_url']['url'], 'data:;base64,'); + && str_starts_with((string) $imageBlock['image_url']['url'], 'data:image/png;base64,') + && ! str_contains((string) $imageBlock['image_url']['url'], 'data:;base64,'); }); }); diff --git a/tests/Feature/Providers/DeepSeek/StreamingTest.php b/tests/Feature/Providers/DeepSeek/StreamingTest.php index bb7fe972d..27c16e889 100644 --- a/tests/Feature/Providers/DeepSeek/StreamingTest.php +++ b/tests/Feature/Providers/DeepSeek/StreamingTest.php @@ -14,14 +14,14 @@ uses(DeepSeekHelpers::class); -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.deepseek' => [ ...config('ai.providers.deepseek'), 'key' => 'test-key', ]]); }); -test('streaming emits text events', function () { +test('streaming emits text events', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::response( body: $this->ssePayload([ @@ -45,7 +45,7 @@ ->and($events[count($events) - 1])->toBeInstanceOf(StreamEnd::class); }); -test('streaming handles tool calls', function () { +test('streaming handles tool calls', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::sequence([ Http::response( @@ -72,14 +72,14 @@ $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); - $toolCallEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolCallEvent)); + $toolCallEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolCallEvent)); expect($toolCallEvents)->not->toBeEmpty() ->and($toolCallEvents[0]->toolCall->name)->toBe('FixedNumberGenerator') ->and($toolCallEvents[0]->toolCall->id)->toBe('call_1'); }); -test('streaming error event stops stream', function () { +test('streaming error event stops stream', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::response( body: $this->ssePayload([ @@ -98,7 +98,7 @@ ->and($events[0]->message)->toBe('Rate limit exceeded'); }); -test('streaming captures usage from final chunk', function () { +test('streaming captures usage from final chunk', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::response( body: $this->ssePayload([ @@ -113,13 +113,13 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->usage->promptTokens)->toBe(42) ->and($streamEnd->usage->completionTokens)->toBe(10); }); -test('streaming captures cache hit and reasoning tokens', function () { +test('streaming captures cache hit and reasoning tokens', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::response( body: $this->ssePayload([ @@ -143,7 +143,7 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->usage->promptTokens)->toBe(100) ->and($streamEnd->usage->completionTokens)->toBe(50) @@ -152,7 +152,7 @@ ->and($streamEnd->usage->reasoningTokens)->toBe(12); }); -test('streaming finish reason maps correctly', function (string $apiReason, $expected) { +test('streaming finish reason maps correctly', function (string $apiReason, $expected): void { Http::fake([ 'api.deepseek.com/*' => Http::response( body: $this->ssePayload([ @@ -167,7 +167,7 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->reason)->toBe($expected->value); })->with([ diff --git a/tests/Feature/Providers/DeepSeek/ToolCallLoopTest.php b/tests/Feature/Providers/DeepSeek/ToolCallLoopTest.php index ba64f2be3..501b2b9f0 100644 --- a/tests/Feature/Providers/DeepSeek/ToolCallLoopTest.php +++ b/tests/Feature/Providers/DeepSeek/ToolCallLoopTest.php @@ -3,17 +3,18 @@ use GuzzleHttp\Promise\PromiseInterface; use Illuminate\Support\Facades\Http; use Laravel\Ai\Exceptions\NoSuchToolException; +use Laravel\Ai\Responses\AgentResponse; use Tests\Fixtures\Agents\MultiStepToolAgent; use Tests\Fixtures\Agents\ToolUsingAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.deepseek' => [ ...config('ai.providers.deepseek'), 'key' => 'test-key', ]]); }); -test('tool calls trigger follow up request', function () { +test('tool calls trigger follow up request', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::sequence([ fakeUniqueDeepSeekToolCallResponse(), @@ -30,7 +31,7 @@ expect($recorded)->toHaveCount(2); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); $hasAssistantWithToolCalls = false; $hasToolResult = false; @@ -49,7 +50,7 @@ ->and($hasToolResult)->toBeTrue(); }); -test('max steps limits tool call depth', function () { +test('max steps limits tool call depth', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::sequence([ fakeUniqueDeepSeekToolCallResponse(), @@ -69,7 +70,7 @@ expect(count($recorded))->toBeLessThanOrEqual(3); }); -test('multi step tool loop returns accumulated response shape', function () { +test('multi step tool loop returns accumulated response shape', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::sequence([ fakeUniqueDeepSeekToolCallResponse(), @@ -92,7 +93,7 @@ ->and($response->usage->completionTokens)->toBe(11); }); -test('unknown tool call throws no such tool exception', function () { +test('unknown tool call throws no such tool exception', function (): void { Http::fake([ 'api.deepseek.com/*' => Http::response([ 'id' => 'chatcmpl-tool-unknown', @@ -121,7 +122,7 @@ ]), ]); - expect(fn () => (new MultiStepToolAgent)->prompt( + expect(fn (): AgentResponse => (new MultiStepToolAgent)->prompt( 'Generate numbers', provider: 'deepseek', ))->toThrow(NoSuchToolException::class); diff --git a/tests/Feature/Providers/DeepSeek/ToolMappingTest.php b/tests/Feature/Providers/DeepSeek/ToolMappingTest.php index 7a27d5f43..62f5f5a0a 100644 --- a/tests/Feature/Providers/DeepSeek/ToolMappingTest.php +++ b/tests/Feature/Providers/DeepSeek/ToolMappingTest.php @@ -7,21 +7,21 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.deepseek' => [ ...config('ai.providers.deepseek'), 'key' => 'test-key', ]]); }); -test('tool with parameters includes correct schema', function () { +test('tool with parameters includes correct schema', function (): void { Http::fake([ '*' => fakeDeepSeekResponse('42'), ]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a random number', provider: 'deepseek'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; @@ -35,14 +35,14 @@ }); }); -test('tool with empty schema includes parameters', function () { +test('tool with empty schema includes parameters', function (): void { Http::fake([ '*' => fakeDeepSeekResponse('72019'), ]); agent(tools: [new FixedNumberGenerator])->prompt('Give me a number', provider: 'deepseek'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; @@ -55,14 +55,14 @@ }); }); -test('tool parameters are not wrapped in schema definition', function () { +test('tool parameters are not wrapped in schema definition', function (): void { Http::fake([ '*' => fakeDeepSeekResponse('done'), ]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a random number', provider: 'deepseek'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; diff --git a/tests/Feature/Providers/ElevenLabs/AudioTest.php b/tests/Feature/Providers/ElevenLabs/AudioTest.php index 284dd1371..11ac6bd7f 100644 --- a/tests/Feature/Providers/ElevenLabs/AudioTest.php +++ b/tests/Feature/Providers/ElevenLabs/AudioTest.php @@ -7,19 +7,19 @@ use Laravel\Ai\Exceptions\ProviderOverloadedException; use Laravel\Ai\Exceptions\RateLimitedException; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.eleven' => [ ...config('ai.providers.eleven'), 'key' => 'test-key', ]]); }); -test('audio request includes model_id, text, and resolves default-female voice', function () { +test('audio request includes model_id, text, and resolves default-female voice', function (): void { Http::fake(['*' => fakeElevenAudioResponse()]); Audio::of('Hello world')->generate(provider: 'eleven', model: 'eleven_multilingual_v2'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model_id'] === 'eleven_multilingual_v2' @@ -28,23 +28,23 @@ }); }); -test('audio request resolves default-male voice alias', function () { +test('audio request resolves default-male voice alias', function (): void { Http::fake(['*' => fakeElevenAudioResponse()]); Audio::of('Hello')->male()->generate(provider: 'eleven', model: 'eleven_multilingual_v2'); - Http::assertSent(fn (Request $request) => $request->url() === 'https://api.elevenlabs.io/v1/text-to-speech/onwK4e9ZLuTAKqWW03F9'); + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.elevenlabs.io/v1/text-to-speech/onwK4e9ZLuTAKqWW03F9'); }); -test('audio request passes custom voice id through unchanged', function () { +test('audio request passes custom voice id through unchanged', function (): void { Http::fake(['*' => fakeElevenAudioResponse()]); Audio::of('Hello')->voice('my-custom-voice-id')->generate(provider: 'eleven', model: 'eleven_multilingual_v2'); - Http::assertSent(fn (Request $request) => $request->url() === 'https://api.elevenlabs.io/v1/text-to-speech/my-custom-voice-id'); + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.elevenlabs.io/v1/text-to-speech/my-custom-voice-id'); }); -test('audio request sends xi-api-key header', function () { +test('audio request sends xi-api-key header', function (): void { Http::fake(['*' => fakeElevenAudioResponse()]); Audio::of('Hello')->generate(provider: 'eleven', model: 'eleven_multilingual_v2'); @@ -52,7 +52,7 @@ Http::assertSent(fn (Request $request) => $request->hasHeader('xi-api-key', 'test-key')); }); -test('audio response is base64-encoded with audio/mpeg mime type', function () { +test('audio response is base64-encoded with audio/mpeg mime type', function (): void { Http::fake(['*' => Http::response('raw-audio-bytes')]); $response = Audio::of('Hello')->generate(provider: 'eleven', model: 'eleven_multilingual_v2'); @@ -63,27 +63,27 @@ ->and($response->meta->model)->toBe('eleven_multilingual_v2'); }); -test('audio uses default model when none specified', function () { +test('audio uses default model when none specified', function (): void { Http::fake(['*' => fakeElevenAudioResponse()]); Audio::of('Hello')->generate(provider: 'eleven'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['model_id'] === 'eleven_multilingual_v2'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['model_id'] === 'eleven_multilingual_v2'); }); -test('audio throws when the API returns an error', function () { +test('audio throws when the API returns an error', function (): void { Http::fake(['*' => Http::response(['detail' => 'unauthorized'], 401)]); Audio::of('Hello')->generate(provider: 'eleven', model: 'eleven_multilingual_v2'); })->throws(RequestException::class); -test('audio rate limit response throws rate limited exception', function () { +test('audio rate limit response throws rate limited exception', function (): void { Http::fake(['api.elevenlabs.io/*' => Http::response(['detail' => 'rate limit exceeded'], 429)]); Audio::of('Hello')->generate(provider: 'eleven', model: 'eleven_multilingual_v2'); })->throws(RateLimitedException::class); -test('audio overloaded response throws provider overloaded exception', function () { +test('audio overloaded response throws provider overloaded exception', function (): void { Http::fake(['api.elevenlabs.io/*' => Http::response(['detail' => 'service unavailable'], 503)]); Audio::of('Hello')->generate(provider: 'eleven', model: 'eleven_multilingual_v2'); diff --git a/tests/Feature/Providers/ElevenLabs/BaseUrlTest.php b/tests/Feature/Providers/ElevenLabs/BaseUrlTest.php index 7d28149fc..3aa49982c 100644 --- a/tests/Feature/Providers/ElevenLabs/BaseUrlTest.php +++ b/tests/Feature/Providers/ElevenLabs/BaseUrlTest.php @@ -16,7 +16,7 @@ function fakeElevenBaseUrlTranscriptionResponse(): PromiseInterface return Http::response(['text' => 'Hello world']); } -test('elevenlabs audio requests use the configured base url', function () { +test('elevenlabs audio requests use the configured base url', function (): void { config(['ai.providers.eleven' => [ ...config('ai.providers.eleven'), 'key' => 'test-key', @@ -27,10 +27,10 @@ function fakeElevenBaseUrlTranscriptionResponse(): PromiseInterface Audio::of('Hello')->generate(provider: 'eleven', model: 'eleven_multilingual_v2'); - Http::assertSent(fn (Request $r) => str_starts_with($r->url(), 'http://localhost:8080/v1/text-to-speech/')); + Http::assertSent(fn (Request $r): bool => str_starts_with($r->url(), 'http://localhost:8080/v1/text-to-speech/')); }); -test('elevenlabs transcription requests use the configured base url', function () { +test('elevenlabs transcription requests use the configured base url', function (): void { config(['ai.providers.eleven' => [ ...config('ai.providers.eleven'), 'key' => 'test-key', @@ -42,10 +42,10 @@ function fakeElevenBaseUrlTranscriptionResponse(): PromiseInterface Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->generate(provider: 'eleven', model: 'scribe_v2'); - Http::assertSent(fn (Request $r) => $r->url() === 'http://localhost:8080/v1/speech-to-text'); + Http::assertSent(fn (Request $r): bool => $r->url() === 'http://localhost:8080/v1/speech-to-text'); }); -test('elevenlabs requests fall back to the default base url', function () { +test('elevenlabs requests fall back to the default base url', function (): void { config(['ai.providers.eleven' => array_diff_key( [...config('ai.providers.eleven'), 'key' => 'test-key'], ['url' => null], @@ -55,5 +55,5 @@ function fakeElevenBaseUrlTranscriptionResponse(): PromiseInterface Audio::of('Hello')->generate(provider: 'eleven', model: 'eleven_multilingual_v2'); - Http::assertSent(fn (Request $r) => str_starts_with($r->url(), 'https://api.elevenlabs.io/v1/text-to-speech/')); + Http::assertSent(fn (Request $r): bool => str_starts_with($r->url(), 'https://api.elevenlabs.io/v1/text-to-speech/')); }); diff --git a/tests/Feature/Providers/ElevenLabs/ErrorHandlingTest.php b/tests/Feature/Providers/ElevenLabs/ErrorHandlingTest.php index e89b5444a..af7e95685 100644 --- a/tests/Feature/Providers/ElevenLabs/ErrorHandlingTest.php +++ b/tests/Feature/Providers/ElevenLabs/ErrorHandlingTest.php @@ -7,14 +7,14 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Laravel\Ai\Transcription; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.eleven' => [ ...config('ai.providers.eleven'), 'key' => 'test-key', ]]); }); -test('audio rate limit response throws rate limited exception', function () { +test('audio rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.elevenlabs.io/*' => Http::response(['detail' => 'Rate limit exceeded'], 429), ]); @@ -22,7 +22,7 @@ Audio::of('Hello world')->generate(provider: 'eleven', model: 'eleven_multilingual_v2'); })->throws(RateLimitedException::class); -test('audio overloaded response throws provider overloaded exception', function () { +test('audio overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.elevenlabs.io/*' => Http::response(['detail' => 'Service overloaded'], 503), ]); @@ -30,7 +30,7 @@ Audio::of('Hello world')->generate(provider: 'eleven', model: 'eleven_multilingual_v2'); })->throws(ProviderOverloadedException::class); -test('audio http error response throws request exception', function () { +test('audio http error response throws request exception', function (): void { Http::fake([ 'api.elevenlabs.io/*' => Http::response(['detail' => 'Unauthorized'], 401), ]); @@ -38,7 +38,7 @@ Audio::of('Hello world')->generate(provider: 'eleven', model: 'eleven_multilingual_v2'); })->throws(RequestException::class); -test('transcription rate limit response throws rate limited exception', function () { +test('transcription rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.elevenlabs.io/*' => Http::response(['detail' => 'Rate limit exceeded'], 429), ]); @@ -46,7 +46,7 @@ Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3')->generate(provider: 'eleven'); })->throws(RateLimitedException::class); -test('transcription overloaded response throws provider overloaded exception', function () { +test('transcription overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.elevenlabs.io/*' => Http::response(['detail' => 'Service overloaded'], 503), ]); @@ -54,7 +54,7 @@ Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3')->generate(provider: 'eleven'); })->throws(ProviderOverloadedException::class); -test('transcription http error response throws request exception', function () { +test('transcription http error response throws request exception', function (): void { Http::fake([ 'api.elevenlabs.io/*' => Http::response(['detail' => 'Unauthorized'], 401), ]); diff --git a/tests/Feature/Providers/ElevenLabs/GatewayCachingTest.php b/tests/Feature/Providers/ElevenLabs/GatewayCachingTest.php index 208471062..de27a0453 100644 --- a/tests/Feature/Providers/ElevenLabs/GatewayCachingTest.php +++ b/tests/Feature/Providers/ElevenLabs/GatewayCachingTest.php @@ -2,20 +2,20 @@ use Laravel\Ai\Ai; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.eleven' => [ ...config('ai.providers.eleven'), 'key' => 'test-key', ]]); }); -test('audio gateway is memoized across calls', function () { +test('audio gateway is memoized across calls', function (): void { $provider = Ai::audioProvider('eleven'); expect($provider->audioGateway())->toBe($provider->audioGateway()); }); -test('transcription gateway is memoized across calls', function () { +test('transcription gateway is memoized across calls', function (): void { $provider = Ai::transcriptionProvider('eleven'); expect($provider->transcriptionGateway())->toBe($provider->transcriptionGateway()); diff --git a/tests/Feature/Providers/ElevenLabs/TranscriptionTest.php b/tests/Feature/Providers/ElevenLabs/TranscriptionTest.php index f2b1b7d5e..90c54d902 100644 --- a/tests/Feature/Providers/ElevenLabs/TranscriptionTest.php +++ b/tests/Feature/Providers/ElevenLabs/TranscriptionTest.php @@ -5,30 +5,28 @@ use Illuminate\Support\Facades\Http; use Laravel\Ai\Transcription; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.eleven' => [ ...config('ai.providers.eleven'), 'key' => 'test-key', ]]); }); -test('transcription request posts to speech-to-text with model, language, and diarize flag', function () { +test('transcription request posts to speech-to-text with model, language, and diarize flag', function (): void { Http::fake(['*' => fakeElevenTranscriptionResponse()]); Transcription::of(base64_encode('fake-audio')) ->language('en') ->generate(provider: 'eleven', model: 'scribe_v2'); - Http::assertSent(function (Request $request) { - return $request->url() === 'https://api.elevenlabs.io/v1/speech-to-text' - && $request->isMultipart() - && collect($request->data())->contains(fn ($part) => $part['name'] === 'model_id' && $part['contents'] === 'scribe_v2') - && collect($request->data())->contains(fn ($part) => $part['name'] === 'language' && $part['contents'] === 'en') - && collect($request->data())->contains(fn ($part) => $part['name'] === 'diarize' && $part['contents'] === 'false'); - }); + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.elevenlabs.io/v1/speech-to-text' + && $request->isMultipart() + && collect($request->data())->contains(fn ($part): bool => $part['name'] === 'model_id' && $part['contents'] === 'scribe_v2') + && collect($request->data())->contains(fn ($part): bool => $part['name'] === 'language' && $part['contents'] === 'en') + && collect($request->data())->contains(fn ($part): bool => $part['name'] === 'diarize' && $part['contents'] === 'false')); }); -test('transcription request sends diarize=true when enabled', function () { +test('transcription request sends diarize=true when enabled', function (): void { Http::fake(['*' => fakeElevenTranscriptionResponse(diarized: true)]); Transcription::of(base64_encode('fake-audio')) @@ -37,11 +35,11 @@ Http::assertSent( fn (Request $request) => collect($request->data()) - ->contains(fn ($part) => $part['name'] === 'diarize' && $part['contents'] === 'true') + ->contains(fn ($part): bool => $part['name'] === 'diarize' && $part['contents'] === 'true') ); }); -test('transcription request sends xi-api-key header', function () { +test('transcription request sends xi-api-key header', function (): void { Http::fake(['*' => fakeElevenTranscriptionResponse()]); Transcription::of(base64_encode('fake-audio'))->generate(provider: 'eleven', model: 'scribe_v2'); @@ -49,7 +47,7 @@ Http::assertSent(fn (Request $request) => $request->hasHeader('xi-api-key', 'test-key')); }); -test('transcription response returns plain text with no segments when diarize is off', function () { +test('transcription response returns plain text with no segments when diarize is off', function (): void { Http::fake(['*' => fakeElevenTranscriptionResponse()]); $response = Transcription::of(base64_encode('fake-audio'))->generate(provider: 'eleven', model: 'scribe_v2'); @@ -60,7 +58,7 @@ ->and($response->meta->model)->toBe('scribe_v2'); }); -test('transcription response builds segments from words when diarize is on', function () { +test('transcription response builds segments from words when diarize is on', function (): void { Http::fake(['*' => fakeElevenTranscriptionResponse(diarized: true)]); $response = Transcription::of(base64_encode('fake-audio')) @@ -76,7 +74,7 @@ ->and($response->segments[1]->speaker)->toBe('speaker_1'); }); -test('transcription filters out non-word segments (e.g. spacing)', function () { +test('transcription filters out non-word segments (e.g. spacing)', function (): void { Http::fake(['*' => Http::response([ 'text' => 'Hello world', 'words' => [ @@ -94,18 +92,18 @@ ->and($response->segments->pluck('text')->all())->toBe(['Hello', 'world']); }); -test('transcription uses default model when none specified', function () { +test('transcription uses default model when none specified', function (): void { Http::fake(['*' => fakeElevenTranscriptionResponse()]); Transcription::of(base64_encode('fake-audio'))->generate(provider: 'eleven'); Http::assertSent( fn (Request $request) => collect($request->data()) - ->contains(fn ($part) => $part['name'] === 'model_id' && $part['contents'] === 'scribe_v2') + ->contains(fn ($part): bool => $part['name'] === 'model_id' && $part['contents'] === 'scribe_v2') ); }); -test('transcription throws when the API returns an error', function () { +test('transcription throws when the API returns an error', function (): void { Http::fake(['*' => Http::response(['detail' => 'unauthorized'], 401)]); Transcription::of(base64_encode('fake-audio'))->generate(provider: 'eleven', model: 'scribe_v2'); diff --git a/tests/Feature/Providers/Gemini/AgentFakeTest.php b/tests/Feature/Providers/Gemini/AgentFakeTest.php index 888dc960d..f03d9c461 100644 --- a/tests/Feature/Providers/Gemini/AgentFakeTest.php +++ b/tests/Feature/Providers/Gemini/AgentFakeTest.php @@ -2,7 +2,7 @@ use Tests\Fixtures\Agents\GeminiAgent; -test('gemini agent can be faked', function () { +test('gemini agent can be faked', function (): void { GeminiAgent::fake(['Test response']); $response = (new GeminiAgent)->prompt('Hello'); @@ -10,15 +10,15 @@ expect($response->text)->toBe('Test response'); }); -test('gemini agent fake with closure', function () { - GeminiAgent::fake(fn (string $prompt) => "Echo: {$prompt}"); +test('gemini agent fake with closure', function (): void { + GeminiAgent::fake(fn (string $prompt): string => "Echo: {$prompt}"); $response = (new GeminiAgent)->prompt('Hello world'); expect($response->text)->toBe('Echo: Hello world'); }); -test('gemini agent fake with no predefined responses', function () { +test('gemini agent fake with no predefined responses', function (): void { GeminiAgent::fake(); $response = (new GeminiAgent)->prompt('Hello'); @@ -26,7 +26,7 @@ expect($response->text)->toBe('Fake response for prompt: Hello'); }); -test('gemini agent fake records prompts', function () { +test('gemini agent fake records prompts', function (): void { GeminiAgent::fake(); (new GeminiAgent)->prompt('Hello'); @@ -35,11 +35,11 @@ GeminiAgent::assertNotPrompted('Goodbye'); }); -test('gemini agent stream can be faked', function () { +test('gemini agent stream can be faked', function (): void { GeminiAgent::fake(['Streamed response']); $response = (new GeminiAgent)->stream('Hello'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->text)->toBe('Streamed response'); }); diff --git a/tests/Feature/Providers/Gemini/AudioTest.php b/tests/Feature/Providers/Gemini/AudioTest.php index 9aa196122..a9d5a714e 100644 --- a/tests/Feature/Providers/Gemini/AudioTest.php +++ b/tests/Feature/Providers/Gemini/AudioTest.php @@ -5,14 +5,14 @@ use Illuminate\Support\Facades\Http; use Laravel\Ai\Audio; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.gemini' => [ ...config('ai.providers.gemini'), 'key' => 'test-key', ]]); }); -test('audio request includes model, prompt text, and voice name', function () { +test('audio request includes model, prompt text, and voice name', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiAudioResponse(), ]); @@ -21,7 +21,7 @@ ->voice('Kore') ->generate(provider: 'gemini', model: 'gemini-2.5-flash-preview-tts'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = $request->data(); return str_contains($request->url(), 'models/gemini-2.5-flash-preview-tts:generateContent') @@ -31,7 +31,7 @@ }); }); -test('audio request resolves default voice aliases', function () { +test('audio request resolves default voice aliases', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiAudioResponse(), ]); @@ -39,11 +39,11 @@ Audio::of('Hello world')->generate(provider: 'gemini', model: 'gemini-2.5-flash-preview-tts'); Audio::of('Hello world')->male()->generate(provider: 'gemini', model: 'gemini-2.5-flash-preview-tts'); - Http::assertSent(fn (Request $request) => $request->data()['generationConfig']['speechConfig']['voiceConfig']['prebuiltVoiceConfig']['voiceName'] === 'Kore'); - Http::assertSent(fn (Request $request) => $request->data()['generationConfig']['speechConfig']['voiceConfig']['prebuiltVoiceConfig']['voiceName'] === 'Puck'); + Http::assertSent(fn (Request $request): bool => $request->data()['generationConfig']['speechConfig']['voiceConfig']['prebuiltVoiceConfig']['voiceName'] === 'Kore'); + Http::assertSent(fn (Request $request): bool => $request->data()['generationConfig']['speechConfig']['voiceConfig']['prebuiltVoiceConfig']['voiceName'] === 'Puck'); }); -test('audio instructions are prepended to the prompt instead of sent in speech config', function () { +test('audio instructions are prepended to the prompt instead of sent in speech config', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiAudioResponse(), ]); @@ -53,7 +53,7 @@ ->instructions('Say cheerfully:') ->generate(provider: 'gemini', model: 'gemini-2.5-flash-preview-tts'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = $request->data(); $speechConfig = $body['generationConfig']['speechConfig']; @@ -64,7 +64,7 @@ }); }); -test('audio response is wrapped as wav with correct meta', function () { +test('audio response is wrapped as wav with correct meta', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiAudioResponse("\x01\x00\x02\x00"), ]); @@ -80,7 +80,7 @@ ->and($response->meta->model)->toBe('gemini-2.5-flash-preview-tts'); }); -test('audio request passes custom voice name through unchanged', function () { +test('audio request passes custom voice name through unchanged', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiAudioResponse(), ]); @@ -89,17 +89,17 @@ ->voice('my-custom-voice') ->generate(provider: 'gemini', model: 'gemini-2.5-flash-preview-tts'); - Http::assertSent(fn (Request $request) => $request->data()['generationConfig']['speechConfig']['voiceConfig']['prebuiltVoiceConfig']['voiceName'] === 'my-custom-voice'); + Http::assertSent(fn (Request $request): bool => $request->data()['generationConfig']['speechConfig']['voiceConfig']['prebuiltVoiceConfig']['voiceName'] === 'my-custom-voice'); }); -test('audio uses default model when none specified', function () { +test('audio uses default model when none specified', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiAudioResponse(), ]); Audio::of('Hello world')->voice('Kore')->generate(provider: 'gemini'); - Http::assertSent(fn (Request $request) => str_contains($request->url(), 'models/gemini-2.5-flash-preview-tts:generateContent')); + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'models/gemini-2.5-flash-preview-tts:generateContent')); }); function fakeGeminiAudioResponse(string $pcm = "\x00\x00"): PromiseInterface diff --git a/tests/Feature/Providers/Gemini/BaseUrlTest.php b/tests/Feature/Providers/Gemini/BaseUrlTest.php index b1a02d11a..a929664a5 100644 --- a/tests/Feature/Providers/Gemini/BaseUrlTest.php +++ b/tests/Feature/Providers/Gemini/BaseUrlTest.php @@ -3,7 +3,7 @@ use Illuminate\Support\Facades\Http; use Tests\Fixtures\Agents\AssistantAgent; -test('gemini requests use the configured base url', function () { +test('gemini requests use the configured base url', function (): void { config(['ai.providers.gemini.url' => 'https://custom-proxy.example.com/v1']); Http::fake([ @@ -15,12 +15,10 @@ provider: 'gemini', ); - Http::assertSent(function ($request) { - return str_contains($request->url(), 'https://custom-proxy.example.com/v1'); - }); + Http::assertSent(fn ($request): bool => str_contains((string) $request->url(), 'https://custom-proxy.example.com/v1')); }); -test('gemini requests fall back to the default base url', function () { +test('gemini requests fall back to the default base url', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse(), ]); @@ -30,7 +28,5 @@ provider: 'gemini', ); - Http::assertSent(function ($request) { - return str_contains($request->url(), 'generativelanguage.googleapis.com'); - }); + Http::assertSent(fn ($request): bool => str_contains((string) $request->url(), 'generativelanguage.googleapis.com')); }); diff --git a/tests/Feature/Providers/Gemini/EmbeddingTest.php b/tests/Feature/Providers/Gemini/EmbeddingTest.php index 5555c1fe5..0de5d2da7 100644 --- a/tests/Feature/Providers/Gemini/EmbeddingTest.php +++ b/tests/Feature/Providers/Gemini/EmbeddingTest.php @@ -8,7 +8,7 @@ use Laravel\Ai\Exceptions\ProviderOverloadedException; use Laravel\Ai\Exceptions\RateLimitedException; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.gemini' => [ ...config('ai.providers.gemini'), 'key' => 'test-key', @@ -27,26 +27,24 @@ function fakeGeminiEmbeddingsResponse(): PromiseInterface ]); } -test('embeddings request posts to batchEmbedContents endpoint', function () { +test('embeddings request posts to batchEmbedContents endpoint', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiEmbeddingsResponse(), ]); Embeddings::for(['Hello world'])->generate(provider: 'gemini', model: 'gemini-embedding-001'); - Http::assertSent(function (Request $request) { - return str_contains($request->url(), 'models/gemini-embedding-001:batchEmbedContents'); - }); + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'models/gemini-embedding-001:batchEmbedContents')); }); -test('embeddings request wraps each input in a request object', function () { +test('embeddings request wraps each input in a request object', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiEmbeddingsResponse(), ]); Embeddings::for(['Hello world'])->generate(provider: 'gemini', model: 'gemini-embedding-001'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $firstRequest = data_get($body, 'requests.0'); @@ -56,7 +54,7 @@ function fakeGeminiEmbeddingsResponse(): PromiseInterface }); }); -test('embeddings response is correctly parsed', function () { +test('embeddings response is correctly parsed', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiEmbeddingsResponse(), ]); @@ -70,7 +68,7 @@ function fakeGeminiEmbeddingsResponse(): PromiseInterface ->and($response->meta->model)->toBe('gemini-embedding-001'); }); -test('multiple inputs are sent as separate requests in the batch', function () { +test('multiple inputs are sent as separate requests in the batch', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'embeddings' => [ @@ -83,7 +81,7 @@ function fakeGeminiEmbeddingsResponse(): PromiseInterface $response = Embeddings::for(['Hello', 'World'])->generate(provider: 'gemini', model: 'gemini-embedding-001'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return count($body['requests']) === 2 @@ -94,21 +92,21 @@ function fakeGeminiEmbeddingsResponse(): PromiseInterface expect($response->embeddings)->toHaveCount(2); }); -test('explicit dimensions are sent as output_dimensionality', function () { +test('explicit dimensions are sent as output_dimensionality', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiEmbeddingsResponse(), ]); Embeddings::for(['Hello world'])->dimensions(768)->generate(provider: 'gemini', model: 'gemini-embedding-001'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'requests.0.output_dimensionality') === 768; }); }); -test('missing embeddings key in response returns empty array', function () { +test('missing embeddings key in response returns empty array', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'usageMetadata' => ['promptTokenCount' => 5], @@ -120,7 +118,7 @@ function fakeGeminiEmbeddingsResponse(): PromiseInterface expect($response->embeddings)->toBe([]); }); -test('missing usageMetadata in response returns zero tokens', function () { +test('missing usageMetadata in response returns zero tokens', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'embeddings' => [['values' => [0.1, 0.2, 0.3]]], @@ -132,7 +130,7 @@ function fakeGeminiEmbeddingsResponse(): PromiseInterface expect($response->tokens)->toBe(0); }); -test('rate limit response throws rate limited exception', function () { +test('rate limit response throws rate limited exception', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response(['error' => ['message' => 'Rate limit exceeded']], 429), ]); @@ -140,7 +138,7 @@ function fakeGeminiEmbeddingsResponse(): PromiseInterface Embeddings::for(['Hello'])->generate(provider: 'gemini', model: 'gemini-embedding-001'); })->throws(RateLimitedException::class); -test('overloaded response throws provider overloaded exception', function () { +test('overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'error' => [ @@ -154,7 +152,7 @@ function fakeGeminiEmbeddingsResponse(): PromiseInterface Embeddings::for(['Hello'])->generate(provider: 'gemini', model: 'gemini-embedding-001'); })->throws(ProviderOverloadedException::class); -test('http error response throws request exception', function () { +test('http error response throws request exception', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response(['error' => ['message' => 'Unauthorized']], 401), ]); @@ -162,19 +160,17 @@ function fakeGeminiEmbeddingsResponse(): PromiseInterface Embeddings::for(['Hello'])->generate(provider: 'gemini', model: 'gemini-embedding-001'); })->throws(RequestException::class); -test('request sends x-goog-api-key header', function () { +test('request sends x-goog-api-key header', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiEmbeddingsResponse(), ]); Embeddings::for(['Hello'])->generate(provider: 'gemini', model: 'gemini-embedding-001'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('x-goog-api-key', 'test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('x-goog-api-key', 'test-key')); }); -test('embeddings request merges provider options into each per-input request', function () { +test('embeddings request merges provider options into each per-input request', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiEmbeddingsResponse(), ]); @@ -183,7 +179,7 @@ function fakeGeminiEmbeddingsResponse(): PromiseInterface ->withProviderOptions(['taskType' => 'RETRIEVAL_QUERY', 'title' => 'doc']) ->generate(provider: 'gemini', model: 'gemini-embedding-001'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); if (array_key_exists('taskType', $body) || array_key_exists('title', $body)) { @@ -204,7 +200,7 @@ function fakeGeminiEmbeddingsResponse(): PromiseInterface }); }); -test('gemini provider options cannot override framework controlled per-request keys', function () { +test('gemini provider options cannot override framework controlled per-request keys', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiEmbeddingsResponse(), ]); @@ -217,7 +213,7 @@ function fakeGeminiEmbeddingsResponse(): PromiseInterface ]) ->generate(provider: 'gemini', model: 'gemini-embedding-001'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $req = $body['requests'][0]; diff --git a/tests/Feature/Providers/Gemini/ErrorHandlingTest.php b/tests/Feature/Providers/Gemini/ErrorHandlingTest.php index 8568b37aa..48c5b7604 100644 --- a/tests/Feature/Providers/Gemini/ErrorHandlingTest.php +++ b/tests/Feature/Providers/Gemini/ErrorHandlingTest.php @@ -7,7 +7,7 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Tests\Fixtures\Agents\AssistantAgent; -test('http error response throws request exception', function () { +test('http error response throws request exception', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'error' => [ @@ -24,7 +24,7 @@ ); })->throws(RequestException::class); -test('rate limit response throws rate limited exception', function () { +test('rate limit response throws rate limited exception', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'error' => [ @@ -41,7 +41,7 @@ ); })->throws(RateLimitedException::class); -test('overloaded response throws provider overloaded exception', function () { +test('overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'error' => [ @@ -58,7 +58,7 @@ ); })->throws(ProviderOverloadedException::class); -test('error in 200 response throws ai exception', function () { +test('error in 200 response throws ai exception', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'error' => [ diff --git a/tests/Feature/Providers/Gemini/FileGatewayTest.php b/tests/Feature/Providers/Gemini/FileGatewayTest.php index b9f632c35..10be74524 100644 --- a/tests/Feature/Providers/Gemini/FileGatewayTest.php +++ b/tests/Feature/Providers/Gemini/FileGatewayTest.php @@ -4,14 +4,14 @@ use Laravel\Ai\Files; use Laravel\Ai\Files\Document; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.gemini' => [ ...config('ai.providers.gemini'), 'key' => 'test-gemini-key', ]]); }); -test('get file sends correct request', function () { +test('get file sends correct request', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'name' => 'files/abc123', @@ -24,14 +24,12 @@ expect($response->id)->toBe('files/abc123'); expect($response->mime)->toBe('text/plain'); - Http::assertSent(function ($request) { - return $request->method() === 'GET' - && str_contains($request->url(), 'v1beta/files/abc123') - && $request->hasHeader('x-goog-api-key', 'test-gemini-key'); - }); + Http::assertSent(fn ($request): bool => $request->method() === 'GET' + && str_contains((string) $request->url(), 'v1beta/files/abc123') + && $request->hasHeader('x-goog-api-key', 'test-gemini-key')); }); -test('get file normalizes id with prefix', function () { +test('get file normalizes id with prefix', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'name' => 'files/abc123', @@ -41,13 +39,11 @@ Files::get('files/abc123', provider: 'gemini'); - Http::assertSent(function ($request) { - return str_contains($request->url(), 'v1beta/files/abc123') - && ! str_contains($request->url(), 'files/files/'); - }); + Http::assertSent(fn ($request): bool => str_contains((string) $request->url(), 'v1beta/files/abc123') + && ! str_contains((string) $request->url(), 'files/files/')); }); -test('put file sends multipart upload', function () { +test('put file sends multipart upload', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'file' => [ @@ -62,14 +58,12 @@ expect($response->id)->toBe('files/uploaded123'); - Http::assertSent(function ($request) { - return $request->method() === 'POST' - && str_contains($request->url(), '/upload/v1beta/files') - && $request->hasHeader('x-goog-api-key', 'test-gemini-key'); - }); + Http::assertSent(fn ($request): bool => $request->method() === 'POST' + && str_contains((string) $request->url(), '/upload/v1beta/files') + && $request->hasHeader('x-goog-api-key', 'test-gemini-key')); }); -test('put file merges flat provider options into the upload body', function () { +test('put file merges flat provider options into the upload body', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response(['file' => ['name' => 'files/uploaded123']]), ]); @@ -84,7 +78,7 @@ ->and(multipartNestedField($request, 'file'))->toBe(['hello.txt']); }); -test('put file provider options override only the file metadata keys they specify', function () { +test('put file provider options override only the file metadata keys they specify', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response(['file' => ['name' => 'files/uploaded123']]), ]); @@ -96,7 +90,7 @@ expect(multipartNestedField(sentRequest(), 'file'))->toBe(['override.txt']); }); -test('put file provider options deep-merge into the file metadata without wiping the display name', function () { +test('put file provider options deep-merge into the file metadata without wiping the display name', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response(['file' => ['name' => 'files/uploaded123']]), ]); @@ -108,34 +102,30 @@ expect(multipartNestedField(sentRequest(), 'file'))->toBe(['hello.txt', 'image/png']); }); -test('delete file sends correct request', function () { +test('delete file sends correct request', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([], 200), ]); Files::delete('abc123', provider: 'gemini'); - Http::assertSent(function ($request) { - return $request->method() === 'DELETE' - && str_contains($request->url(), 'v1beta/files/abc123') - && $request->hasHeader('x-goog-api-key', 'test-gemini-key'); - }); + Http::assertSent(fn ($request): bool => $request->method() === 'DELETE' + && str_contains((string) $request->url(), 'v1beta/files/abc123') + && $request->hasHeader('x-goog-api-key', 'test-gemini-key')); }); -test('delete file normalizes id with prefix', function () { +test('delete file normalizes id with prefix', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([], 200), ]); Files::delete('files/abc123', provider: 'gemini'); - Http::assertSent(function ($request) { - return str_contains($request->url(), 'v1beta/files/abc123') - && ! str_contains($request->url(), 'files/files/'); - }); + Http::assertSent(fn ($request): bool => str_contains((string) $request->url(), 'v1beta/files/abc123') + && ! str_contains((string) $request->url(), 'files/files/')); }); -test('file gateway uses custom base url', function () { +test('file gateway uses custom base url', function (): void { config(['ai.providers.gemini' => [ ...config('ai.providers.gemini'), 'key' => 'test-gemini-key', @@ -151,7 +141,5 @@ Files::get('abc123', provider: 'gemini'); - Http::assertSent(function ($request) { - return str_contains($request->url(), 'custom.api.example.com/v1beta/files/abc123'); - }); + Http::assertSent(fn ($request): bool => str_contains((string) $request->url(), 'custom.api.example.com/v1beta/files/abc123')); }); diff --git a/tests/Feature/Providers/Gemini/ImageGenerationTest.php b/tests/Feature/Providers/Gemini/ImageGenerationTest.php index 9aa750ae5..9ea883543 100644 --- a/tests/Feature/Providers/Gemini/ImageGenerationTest.php +++ b/tests/Feature/Providers/Gemini/ImageGenerationTest.php @@ -9,7 +9,7 @@ use Laravel\Ai\Files\Base64Image; use Laravel\Ai\Image; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.gemini' => [ ...config('ai.providers.gemini'), 'key' => 'test-key', @@ -32,14 +32,14 @@ function fakeGeminiImageResponse(string $mimeType = 'image/png'): PromiseInterfa ]); } -test('image request includes prompt in contents', function () { +test('image request includes prompt in contents', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiImageResponse(), ]); Image::of('A red apple')->generate(provider: 'gemini', model: 'gemini-3.1-flash-image-preview'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'contents.0.role') === 'user' @@ -47,42 +47,42 @@ function fakeGeminiImageResponse(string $mimeType = 'image/png'): PromiseInterfa }); }); -test('image request includes IMAGE and TEXT response modalities', function () { +test('image request includes IMAGE and TEXT response modalities', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiImageResponse(), ]); Image::of('A red apple')->generate(provider: 'gemini', model: 'gemini-3.1-flash-image-preview'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'generationConfig.responseModalities') === ['IMAGE', 'TEXT']; }); }); -test('image request includes default image size when quality not specified', function () { +test('image request includes default image size when quality not specified', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiImageResponse(), ]); Image::of('A red apple')->generate(provider: 'gemini', model: 'gemini-3.1-flash-image-preview'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'generationConfig.imageConfig.imageSize') === '1K'; }); }); -test('image request maps quality to image size', function (string $quality, string $expectedSize) { +test('image request maps quality to image size', function (string $quality, string $expectedSize): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiImageResponse(), ]); Image::of('A red apple')->quality($quality)->generate(provider: 'gemini', model: 'gemini-3.1-flash-image-preview'); - Http::assertSent(function (Request $request) use ($expectedSize) { + Http::assertSent(function (Request $request) use ($expectedSize): bool { $body = json_decode($request->body(), true); return data_get($body, 'generationConfig.imageConfig.imageSize') === $expectedSize; @@ -93,35 +93,35 @@ function fakeGeminiImageResponse(string $mimeType = 'image/png'): PromiseInterfa 'high maps to 4K' => ['high', '4K'], ]); -test('image request maps size to aspect ratio', function () { +test('image request maps size to aspect ratio', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiImageResponse(), ]); Image::of('A red apple')->square()->generate(provider: 'gemini', model: 'gemini-3.1-flash-image-preview'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'generationConfig.imageConfig.aspectRatio') === '1:1'; }); }); -test('image request does not include aspect ratio when size not specified', function () { +test('image request does not include aspect ratio when size not specified', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiImageResponse(), ]); Image::of('A red apple')->generate(provider: 'gemini', model: 'gemini-3.1-flash-image-preview'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('aspectRatio', data_get($body, 'generationConfig.imageConfig', [])); }); }); -test('image attachment is appended to contents parts', function () { +test('image attachment is appended to contents parts', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiImageResponse(), ]); @@ -130,7 +130,7 @@ function fakeGeminiImageResponse(string $mimeType = 'image/png'): PromiseInterfa Image::of('A red apple')->attachments([$attachment])->generate(provider: 'gemini', model: 'gemini-3.1-flash-image-preview'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $parts = data_get($body, 'contents.0.parts'); @@ -140,7 +140,7 @@ function fakeGeminiImageResponse(string $mimeType = 'image/png'): PromiseInterfa }); }); -test('only inlineData parts are returned when response contains mixed text and image parts', function () { +test('only inlineData parts are returned when response contains mixed text and image parts', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'candidates' => [[ @@ -165,7 +165,7 @@ function fakeGeminiImageResponse(string $mimeType = 'image/png'): PromiseInterfa ->and($response->images->first()->mime)->toBe('image/png'); }); -test('firstImage works when response leads with a text part before the inlineData', function () { +test('firstImage works when response leads with a text part before the inlineData', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'candidates' => [[ @@ -191,7 +191,7 @@ function fakeGeminiImageResponse(string $mimeType = 'image/png'): PromiseInterfa ->and((string) $response)->toBe('fake-image'); }); -test('image response is correctly parsed', function () { +test('image response is correctly parsed', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiImageResponse('image/png'), ]); @@ -204,19 +204,17 @@ function fakeGeminiImageResponse(string $mimeType = 'image/png'): PromiseInterfa ->and($response->meta->provider)->toBe('gemini'); }); -test('request sends x-goog-api-key header', function () { +test('request sends x-goog-api-key header', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiImageResponse(), ]); Image::of('A red apple')->generate(provider: 'gemini', model: 'gemini-3.1-flash-image-preview'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('x-goog-api-key', 'test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('x-goog-api-key', 'test-key')); }); -test('image response includes usage metadata when returned', function () { +test('image response includes usage metadata when returned', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'candidates' => [[ @@ -243,7 +241,7 @@ function fakeGeminiImageResponse(string $mimeType = 'image/png'): PromiseInterfa ->and($response->usage->completionTokens)->toBe(1290); }); -test('image response defaults to zero usage when usage metadata absent', function () { +test('image response defaults to zero usage when usage metadata absent', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiImageResponse(), ]); @@ -254,7 +252,7 @@ function fakeGeminiImageResponse(string $mimeType = 'image/png'): PromiseInterfa ->and($response->usage->completionTokens)->toBe(0); }); -test('image rate limit response throws rate limited exception', function () { +test('image rate limit response throws rate limited exception', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'error' => [ @@ -268,7 +266,7 @@ function fakeGeminiImageResponse(string $mimeType = 'image/png'): PromiseInterfa Image::of('A red apple')->generate(provider: 'gemini', model: 'gemini-3.1-flash-image-preview'); })->throws(RateLimitedException::class); -test('image overloaded response throws provider overloaded exception', function () { +test('image overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'error' => [ @@ -282,7 +280,7 @@ function fakeGeminiImageResponse(string $mimeType = 'image/png'): PromiseInterfa Image::of('A red apple')->generate(provider: 'gemini', model: 'gemini-3.1-flash-image-preview'); })->throws(ProviderOverloadedException::class); -test('image http error response throws request exception', function () { +test('image http error response throws request exception', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'error' => [ @@ -296,7 +294,7 @@ function fakeGeminiImageResponse(string $mimeType = 'image/png'): PromiseInterfa Image::of('A red apple')->generate(provider: 'gemini', model: 'gemini-3.1-flash-image-preview'); })->throws(RequestException::class); -test('image response is empty when prompt is blocked and candidates array is empty', function () { +test('image response is empty when prompt is blocked and candidates array is empty', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'candidates' => [], @@ -311,7 +309,7 @@ function fakeGeminiImageResponse(string $mimeType = 'image/png'): PromiseInterfa expect($response->images)->toHaveCount(0); }); -test('image response is empty when candidate is blocked with no content parts', function () { +test('image response is empty when candidate is blocked with no content parts', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'candidates' => [[ diff --git a/tests/Feature/Providers/Gemini/MessageMappingTest.php b/tests/Feature/Providers/Gemini/MessageMappingTest.php index 79b17b281..2636baa67 100644 --- a/tests/Feature/Providers/Gemini/MessageMappingTest.php +++ b/tests/Feature/Providers/Gemini/MessageMappingTest.php @@ -18,7 +18,7 @@ use function Laravel\Ai\agent; -test('user message maps to gemini format', function () { +test('user message maps to gemini format', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse(), ]); @@ -28,7 +28,7 @@ provider: 'gemini', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $contents = $request->data()['contents']; $userMessage = $contents[0]; @@ -37,7 +37,7 @@ }); }); -test('tool result follow up maps model and function response', function () { +test('tool result follow up maps model and function response', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('The number is 72019'), ]); @@ -104,7 +104,7 @@ public function messages(): iterable ->and(array_is_list($userMessageParts))->toBeTrue('Tool result parts must be a sequential array'); }); -test('prior assistant tool call with empty arguments omits args in conversation history', function () { +test('prior assistant tool call with empty arguments omits args in conversation history', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('OK'), ]); @@ -131,18 +131,18 @@ public function messages(): iterable $agent->prompt('And again', provider: 'gemini'); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $modelFunctionCall = collect($request->data()['contents']) ->where('role', 'model') ->flatMap(fn ($content) => $content['parts'] ?? []) - ->firstWhere(fn ($part) => isset($part['functionCall']))['functionCall'] ?? null; + ->firstWhere(fn ($part): bool => isset($part['functionCall']))['functionCall'] ?? null; return $modelFunctionCall !== null && ! array_key_exists('args', $modelFunctionCall); }); }); -test('local image attachment without explicit mime type detects mime from file', function () { +test('local image attachment without explicit mime type detects mime from file', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('I see an image'), ]); @@ -153,7 +153,7 @@ public function messages(): iterable provider: 'gemini', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $parts = $request->data()['contents'][0]['parts']; foreach ($parts as $part) { @@ -166,7 +166,7 @@ public function messages(): iterable }); }); -test('base64 pdf document maps to inline data', function () { +test('base64 pdf document maps to inline data', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('I see a PDF'), ]); @@ -179,7 +179,7 @@ public function messages(): iterable provider: 'gemini', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $parts = $request->data()['contents'][0]['parts']; foreach ($parts as $part) { @@ -193,7 +193,7 @@ public function messages(): iterable }); }); -test('stored text document sends real mime type', function () { +test('stored text document sends real mime type', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse(), ]); @@ -207,7 +207,7 @@ public function messages(): iterable provider: 'gemini', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $parts = $request->data()['contents'][0]['parts']; foreach ($parts as $part) { @@ -221,7 +221,7 @@ public function messages(): iterable }); }); -test('system instructions are not in contents array', function () { +test('system instructions are not in contents array', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse(), ]); @@ -231,7 +231,7 @@ public function messages(): iterable provider: 'gemini', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); foreach ($body['contents'] as $content) { diff --git a/tests/Feature/Providers/Gemini/ProviderOptionsTest.php b/tests/Feature/Providers/Gemini/ProviderOptionsTest.php index 01c83347f..b6bcd2343 100644 --- a/tests/Feature/Providers/Gemini/ProviderOptionsTest.php +++ b/tests/Feature/Providers/Gemini/ProviderOptionsTest.php @@ -9,7 +9,7 @@ use Tests\Fixtures\Agents\ProviderOptionsAgent; use Tests\Fixtures\Agents\ProviderOptionsWithToolsAgent; -test('provider options are included in generation config', function () { +test('provider options are included in generation config', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse(), ]); @@ -19,7 +19,7 @@ provider: 'gemini', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); $config = $body['generationConfig'] ?? []; @@ -28,7 +28,7 @@ }); }); -test('request body does not contain provider options when agent does not implement interface', function () { +test('request body does not contain provider options when agent does not implement interface', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse(), ]); @@ -38,14 +38,14 @@ provider: 'gemini', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $config = $request->data()['generationConfig'] ?? []; return ! isset($config['thinkingConfig']); }); }); -test('provider options are persisted in tool call follow up requests', function () { +test('provider options are persisted in tool call follow up requests', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ $this->fakeToolCallResponse(), @@ -72,7 +72,7 @@ ->and($secondConfig['thinkingConfig']['thinkingBudget'])->toBe(10000); }); -test('cachedContent is placed at top level of request body, not in generationConfig', function () { +test('cachedContent is placed at top level of request body, not in generationConfig', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse(), ]); @@ -97,7 +97,7 @@ public function providerOptions(Lab|string $provider): array $agent->prompt('Hi', provider: 'gemini'); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); return isset($body['cachedContent']) diff --git a/tests/Feature/Providers/Gemini/RequestMappingTest.php b/tests/Feature/Providers/Gemini/RequestMappingTest.php index 3d4aef533..57e576b3f 100644 --- a/tests/Feature/Providers/Gemini/RequestMappingTest.php +++ b/tests/Feature/Providers/Gemini/RequestMappingTest.php @@ -10,8 +10,8 @@ use Tests\Fixtures\Agents\ToolChoiceAgent; use Tests\Fixtures\Agents\ToolUsingAgent; -describe('request structure', function () { - test('request includes model in url and contents', function () { +describe('request structure', function (): void { + test('request includes model in url and contents', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('Laravel is great'), ]); @@ -22,14 +22,12 @@ model: 'gemini-3.5-flash', ); - Http::assertSent(function ($request) { - return str_contains($request->url(), 'models/gemini-3.5-flash:generateContent') - && $request->data()['contents'][0]['role'] === 'user' - && $request->data()['contents'][0]['parts'][0]['text'] === 'What is Laravel?'; - }); + Http::assertSent(fn ($request): bool => str_contains((string) $request->url(), 'models/gemini-3.5-flash:generateContent') + && $request->data()['contents'][0]['role'] === 'user' + && $request->data()['contents'][0]['parts'][0]['text'] === 'What is Laravel?'); }); - test('system instructions are sent as system instruction field', function () { + test('system instructions are sent as system instruction field', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse(), ]); @@ -39,16 +37,16 @@ provider: 'gemini', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); return isset($body['system_instruction']) && isset($body['system_instruction']['parts'][0]['text']) - && str_contains($body['system_instruction']['parts'][0]['text'], 'helpful'); + && str_contains((string) $body['system_instruction']['parts'][0]['text'], 'helpful'); }); }); - test('request without tools excludes tool fields', function () { + test('request without tools excludes tool fields', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse(), ]); @@ -58,7 +56,7 @@ provider: 'gemini', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); return ! isset($body['tools']) @@ -66,7 +64,7 @@ }); }); - test('request sends api key header', function () { + test('request sends api key header', function (): void { config(['ai.providers.gemini' => [ ...config('ai.providers.gemini'), 'key' => 'test-key', @@ -81,12 +79,10 @@ provider: 'gemini', ); - Http::assertSent(function ($request) { - return $request->hasHeader('x-goog-api-key', 'test-key'); - }); + Http::assertSent(fn ($request) => $request->hasHeader('x-goog-api-key', 'test-key')); }); - test('request omits the api key header when no key is configured', function () { + test('request omits the api key header when no key is configured', function (): void { config(['ai.providers.gemini' => [ ...config('ai.providers.gemini'), 'key' => null, @@ -101,12 +97,10 @@ provider: 'gemini', ); - Http::assertSent(function ($request) { - return ! $request->hasHeader('x-goog-api-key'); - }); + Http::assertSent(fn ($request): bool => ! $request->hasHeader('x-goog-api-key')); }); - test('tool_config is omitted to rely on Gemini default AUTO mode', function () { + test('tool_config is omitted to rely on Gemini default AUTO mode', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('The number is 42'), ]); @@ -116,7 +110,7 @@ provider: 'gemini', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); return isset($body['tools']) @@ -124,7 +118,7 @@ }); }); - test('function call id is extracted from response', function () { + test('function call id is extracted from response', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ $this->fakeToolCallResponse('FixedNumberGenerator', 'call_abc123'), @@ -145,8 +139,8 @@ }); }); -describe('structured output', function () { - test('structured output uses response json schema', function () { +describe('structured output', function (): void { + test('structured output uses response json schema', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeStructuredResponse(['symbol' => 'Fe']), ]); @@ -156,7 +150,7 @@ provider: 'gemini', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); $config = $body['generationConfig'] ?? []; @@ -166,7 +160,7 @@ }); }); - test('structured response is correctly parsed', function () { + test('structured response is correctly parsed', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeStructuredResponse(['symbol' => 'Fe']), ]); @@ -179,7 +173,7 @@ expect($response->structured['symbol'])->toBe('Fe'); }); - test('nested structured output uses response json schema', function () { + test('nested structured output uses response json schema', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeStructuredResponse([ 'elements' => [['atomicNumber' => 1, 'symbol' => 'H']], @@ -188,7 +182,7 @@ (new NestedStructuredAgent)->prompt('List noble gases?', provider: 'gemini'); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $config = $request->data()['generationConfig'] ?? []; $schema = $config['response_json_schema'] ?? []; $itemSchema = $schema['properties']['elements']['items'] ?? []; @@ -200,7 +194,7 @@ }); }); - test('nullable schema types are preserved in response json schema', function () { + test('nullable schema types are preserved in response json schema', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeStructuredResponse([ 'symbol' => 'He', @@ -211,7 +205,7 @@ (new NullableStructuredAgent)->prompt('Properties of Helium?', provider: 'gemini'); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $schema = $request->data()['generationConfig']['response_json_schema'] ?? []; $props = $schema['properties'] ?? []; @@ -221,8 +215,8 @@ }); }); -describe('usage parsing', function () { - test('response usage is correctly parsed', function () { +describe('usage parsing', function (): void { + test('response usage is correctly parsed', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'candidates' => [[ @@ -254,7 +248,7 @@ ->reasoningTokens->toBe(10); }); - test('usage without cached tokens uses full prompt count', function () { + test('usage without cached tokens uses full prompt count', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'candidates' => [[ @@ -275,7 +269,7 @@ ->completionTokens->toBe(50); }); - test('thinking response parts are separated from text', function () { + test('thinking response parts are separated from text', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'candidates' => [[ @@ -301,7 +295,7 @@ expect($response->text)->toBe('The answer is 42.')->not->toContain('Internal reasoning'); }); - test('finish reason maps correctly', function (string $geminiReason, FinishReason $expected) { + test('finish reason maps correctly', function (string $geminiReason, FinishReason $expected): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'candidates' => [[ @@ -324,8 +318,8 @@ ]); }); -describe('citations', function () { - test('grounding metadata citations are filtered through supports', function () { +describe('citations', function (): void { + test('grounding metadata citations are filtered through supports', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'candidates' => [[ @@ -357,7 +351,7 @@ ->and($response->meta->citations[1]->url)->toBe('https://example.com/spain'); }); - test('legacy citation metadata is also extracted', function () { + test('legacy citation metadata is also extracted', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'candidates' => [[ @@ -382,7 +376,7 @@ ->and($response->meta->citations[0]->url)->toBe('https://example.com/source1'); }); - test('duplicate citations are deduplicated by url', function () { + test('duplicate citations are deduplicated by url', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'candidates' => [[ @@ -411,43 +405,37 @@ }); }); -describe('tool choice', function () { - test('required tool choice sends function calling config in ANY mode', function () { +describe('tool choice', function (): void { + test('required tool choice sends function calling config in ANY mode', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('The number is 42'), ]); (new ToolChoiceAgent('required'))->prompt('Generate a number', provider: 'gemini'); - Http::assertSent(function ($request) { - return $request->data()['tool_config']['function_calling_config'] === ['mode' => 'ANY']; - }); + Http::assertSent(fn ($request): bool => $request->data()['tool_config']['function_calling_config'] === ['mode' => 'ANY']); }); - test('required tool choice can be set via attribute', function () { + test('required tool choice can be set via attribute', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('The number is 42'), ]); (new AttributeToolChoiceAgent)->prompt('Generate a number', provider: 'gemini'); - Http::assertSent(function ($request) { - return $request->data()['tool_config']['function_calling_config'] === ['mode' => 'ANY']; - }); + Http::assertSent(fn ($request): bool => $request->data()['tool_config']['function_calling_config'] === ['mode' => 'ANY']); }); - test('named tool choice restricts the allowed function names', function () { + test('named tool choice restricts the allowed function names', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('The number is 42'), ]); (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Generate a number', provider: 'gemini'); - Http::assertSent(function ($request) { - return $request->data()['tool_config']['function_calling_config'] === [ - 'mode' => 'ANY', - 'allowed_function_names' => ['custom_named_tool'], - ]; - }); + Http::assertSent(fn ($request): bool => $request->data()['tool_config']['function_calling_config'] === [ + 'mode' => 'ANY', + 'allowed_function_names' => ['custom_named_tool'], + ]); }); }); diff --git a/tests/Feature/Providers/Gemini/StoreGatewayTest.php b/tests/Feature/Providers/Gemini/StoreGatewayTest.php index d4c009c59..43406081c 100644 --- a/tests/Feature/Providers/Gemini/StoreGatewayTest.php +++ b/tests/Feature/Providers/Gemini/StoreGatewayTest.php @@ -4,7 +4,7 @@ use Laravel\Ai\AiManager; use Laravel\Ai\Stores; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.gemini' => [ ...config('ai.providers.gemini'), 'key' => 'test-gemini-key', @@ -27,7 +27,7 @@ function fakeStoreResponse(string $name = 'fileSearchStores/store123', string $d ]; } -test('get store sends correct request', function () { +test('get store sends correct request', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response(fakeStoreResponse()), ]); @@ -41,27 +41,23 @@ function fakeStoreResponse(string $name = 'fileSearchStores/store123', string $d expect($store->fileCounts->failed)->toBe(0); expect($store->ready)->toBeTrue(); - Http::assertSent(function ($request) { - return $request->method() === 'GET' - && str_contains($request->url(), 'v1beta/fileSearchStores/store123') - && $request->hasHeader('x-goog-api-key', 'test-gemini-key'); - }); + Http::assertSent(fn ($request): bool => $request->method() === 'GET' + && str_contains((string) $request->url(), 'v1beta/fileSearchStores/store123') + && $request->hasHeader('x-goog-api-key', 'test-gemini-key')); }); -test('get store normalizes id with prefix', function () { +test('get store normalizes id with prefix', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response(fakeStoreResponse()), ]); Stores::get('fileSearchStores/store123', provider: 'gemini'); - Http::assertSent(function ($request) { - return str_contains($request->url(), 'v1beta/fileSearchStores/store123') - && ! str_contains($request->url(), 'fileSearchStores/fileSearchStores/'); - }); + Http::assertSent(fn ($request): bool => str_contains((string) $request->url(), 'v1beta/fileSearchStores/store123') + && ! str_contains((string) $request->url(), 'fileSearchStores/fileSearchStores/')); }); -test('create store sends correct request', function () { +test('create store sends correct request', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response(fakeStoreResponse()), ]); @@ -71,14 +67,12 @@ function fakeStoreResponse(string $name = 'fileSearchStores/store123', string $d expect($store->id)->toBe('fileSearchStores/store123'); expect($store->name)->toBe('Test Store'); - Http::assertSent(function ($request) { - return $request->method() === 'POST' - && str_contains($request->url(), 'v1beta/fileSearchStores') - && ($request->data()['displayName'] ?? null) === 'Test Store'; - }); + Http::assertSent(fn ($request): bool => $request->method() === 'POST' + && str_contains((string) $request->url(), 'v1beta/fileSearchStores') + && ($request->data()['displayName'] ?? null) === 'Test Store'); }); -test('create store with file ids adds files', function () { +test('create store with file ids adds files', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ Http::response(['name' => 'fileSearchStores/store123']), @@ -93,7 +87,7 @@ function fakeStoreResponse(string $name = 'fileSearchStores/store123', string $d Http::assertSentCount(4); }); -test('add file sends correct request', function () { +test('add file sends correct request', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'name' => 'fileSearchStores/store123/documents/doc456', @@ -105,14 +99,12 @@ function fakeStoreResponse(string $name = 'fileSearchStores/store123', string $d expect($documentId)->toBe('doc456'); - Http::assertSent(function ($request) { - return $request->method() === 'POST' - && str_contains($request->url(), 'fileSearchStores/store123:importFile') - && ($request->data()['fileName'] ?? null) === 'files/file789'; - }); + Http::assertSent(fn ($request): bool => $request->method() === 'POST' + && str_contains((string) $request->url(), 'fileSearchStores/store123:importFile') + && ($request->data()['fileName'] ?? null) === 'files/file789'); }); -test('add file with metadata formats correctly', function () { +test('add file with metadata formats correctly', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([ 'name' => 'fileSearchStores/store123/documents/doc456', @@ -126,7 +118,7 @@ function fakeStoreResponse(string $name = 'fileSearchStores/store123', string $d 'tags' => ['php', 'framework'], ]); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $metadata = $request->data()['customMetadata'] ?? []; $string = collect($metadata)->firstWhere('key', 'company'); @@ -139,7 +131,7 @@ function fakeStoreResponse(string $name = 'fileSearchStores/store123', string $d }); }); -test('remove file sends correct request', function () { +test('remove file sends correct request', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([], 200), ]); @@ -149,13 +141,11 @@ function fakeStoreResponse(string $name = 'fileSearchStores/store123', string $d expect($result)->toBeTrue(); - Http::assertSent(function ($request) { - return $request->method() === 'DELETE' - && str_contains($request->url(), 'fileSearchStores/store123/documents/doc456'); - }); + Http::assertSent(fn ($request): bool => $request->method() === 'DELETE' + && str_contains((string) $request->url(), 'fileSearchStores/store123/documents/doc456')); }); -test('remove file normalizes document id with files prefix', function () { +test('remove file normalizes document id with files prefix', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([], 200), ]); @@ -163,12 +153,10 @@ function fakeStoreResponse(string $name = 'fileSearchStores/store123', string $d $provider = geminiProvider(); $provider->storeGateway()->removeFile($provider, 'store123', 'files/doc456'); - Http::assertSent(function ($request) { - return str_contains($request->url(), 'fileSearchStores/store123/documents/doc456'); - }); + Http::assertSent(fn ($request): bool => str_contains((string) $request->url(), 'fileSearchStores/store123/documents/doc456')); }); -test('remove file normalizes document id with documents prefix', function () { +test('remove file normalizes document id with documents prefix', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([], 200), ]); @@ -176,12 +164,10 @@ function fakeStoreResponse(string $name = 'fileSearchStores/store123', string $d $provider = geminiProvider(); $provider->storeGateway()->removeFile($provider, 'store123', 'documents/doc456'); - Http::assertSent(function ($request) { - return str_contains($request->url(), 'fileSearchStores/store123/documents/doc456'); - }); + Http::assertSent(fn ($request): bool => str_contains((string) $request->url(), 'fileSearchStores/store123/documents/doc456')); }); -test('remove file accepts full document path', function () { +test('remove file accepts full document path', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([], 200), ]); @@ -189,13 +175,11 @@ function fakeStoreResponse(string $name = 'fileSearchStores/store123', string $d $provider = geminiProvider(); $provider->storeGateway()->removeFile($provider, 'store123', 'fileSearchStores/store123/documents/doc456'); - Http::assertSent(function ($request) { - return str_contains($request->url(), 'fileSearchStores/store123/documents/doc456') - && ! str_contains($request->url(), 'fileSearchStores/store123/documents/fileSearchStores/'); - }); + Http::assertSent(fn ($request): bool => str_contains((string) $request->url(), 'fileSearchStores/store123/documents/doc456') + && ! str_contains((string) $request->url(), 'fileSearchStores/store123/documents/fileSearchStores/')); }); -test('delete store sends correct request', function () { +test('delete store sends correct request', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response([], 200), ]); @@ -204,14 +188,12 @@ function fakeStoreResponse(string $name = 'fileSearchStores/store123', string $d expect($result)->toBeTrue(); - Http::assertSent(function ($request) { - return $request->method() === 'DELETE' - && str_contains($request->url(), 'v1beta/fileSearchStores/store123') - && $request->hasHeader('x-goog-api-key', 'test-gemini-key'); - }); + Http::assertSent(fn ($request): bool => $request->method() === 'DELETE' + && str_contains((string) $request->url(), 'v1beta/fileSearchStores/store123') + && $request->hasHeader('x-goog-api-key', 'test-gemini-key')); }); -test('store gateway uses custom base url', function () { +test('store gateway uses custom base url', function (): void { config(['ai.providers.gemini' => [ ...config('ai.providers.gemini'), 'key' => 'test-gemini-key', @@ -224,7 +206,5 @@ function fakeStoreResponse(string $name = 'fileSearchStores/store123', string $d Stores::get('store123', provider: 'gemini'); - Http::assertSent(function ($request) { - return str_contains($request->url(), 'custom.api.example.com/v1beta/fileSearchStores/store123'); - }); + Http::assertSent(fn ($request): bool => str_contains((string) $request->url(), 'custom.api.example.com/v1beta/fileSearchStores/store123')); }); diff --git a/tests/Feature/Providers/Gemini/StreamingTest.php b/tests/Feature/Providers/Gemini/StreamingTest.php index a80e0558a..c49be5deb 100644 --- a/tests/Feature/Providers/Gemini/StreamingTest.php +++ b/tests/Feature/Providers/Gemini/StreamingTest.php @@ -14,8 +14,8 @@ use Laravel\Ai\Streaming\Events\ToolCall as ToolCallEvent; use Tests\Fixtures\Agents\ProviderOptionsWithToolsAgent; -describe('text streaming', function () { - test('streaming emits text events', function () { +describe('text streaming', function (): void { + test('streaming emits text events', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response( body: $this->ssePayload([ @@ -38,7 +38,7 @@ ->and($events[count($events) - 1])->toBeInstanceOf(StreamEnd::class); }); - test('streaming uses sse endpoint with alt parameter', function () { + test('streaming uses sse endpoint with alt parameter', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response( body: $this->ssePayload([ @@ -51,14 +51,12 @@ $this->collectStreamEvents(); - Http::assertSent(function ($request) { - return str_contains($request->url(), 'streamGenerateContent?alt=sse'); - }); + Http::assertSent(fn ($request): bool => str_contains((string) $request->url(), 'streamGenerateContent?alt=sse')); }); }); -describe('tool calls', function () { - test('streaming handles tool calls', function () { +describe('tool calls', function (): void { + test('streaming handles tool calls', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ Http::response( @@ -86,13 +84,13 @@ $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); - $toolCallEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolCallEvent)); + $toolCallEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolCallEvent)); expect($toolCallEvents)->not->toBeEmpty() ->and($toolCallEvents[0]->toolCall->name)->toBe('FixedNumberGenerator'); }); - test('streaming tool loop emits a single stream end with accumulated usage', function () { + test('streaming tool loop emits a single stream end with accumulated usage', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ Http::response( @@ -120,7 +118,7 @@ $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); - $streamEnds = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd)); + $streamEnds = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd)); expect($streamEnds)->toHaveCount(1) ->and($streamEnds[0]->reason)->toBe(FinishReason::Stop->value) @@ -129,7 +127,7 @@ ->completionTokens->toBe(15); }); - test('streaming thinking parts are excluded from tool call continuation', function () { + test('streaming thinking parts are excluded from tool call continuation', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ Http::response( @@ -170,8 +168,8 @@ }); }); -describe('thinking blocks', function () { - test('streaming handles thinking parts', function () { +describe('thinking blocks', function (): void { + test('streaming handles thinking parts', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response( body: $this->ssePayload([ @@ -192,13 +190,13 @@ ReasoningEnd::class, ]); - $reasoningDelta = array_values(array_filter($events, fn ($e) => $e instanceof ReasoningDelta))[0]; + $reasoningDelta = array_values(array_filter($events, fn ($e): bool => $e instanceof ReasoningDelta))[0]; expect($reasoningDelta->delta)->toBe('Let me think...'); }); }); -describe('error handling', function () { - test('streaming error event stops stream', function () { +describe('error handling', function (): void { + test('streaming error event stops stream', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response( body: $this->ssePayload([ @@ -216,8 +214,8 @@ }); }); -describe('usage tracking', function () { - test('streaming captures usage from final chunk', function () { +describe('usage tracking', function (): void { + test('streaming captures usage from final chunk', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response( body: $this->ssePayload([ @@ -231,7 +229,7 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->usage) ->promptTokens->toBe(37) @@ -239,7 +237,7 @@ ->cacheReadInputTokens->toBe(5); }); - test('streaming finish reason maps correctly', function (string $geminiReason, $expected) { + test('streaming finish reason maps correctly', function (string $geminiReason, $expected): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::response( body: $this->ssePayload([ @@ -253,7 +251,7 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->reason)->toBe($expected->value); })->with([ diff --git a/tests/Feature/Providers/Gemini/ToolCallLoopTest.php b/tests/Feature/Providers/Gemini/ToolCallLoopTest.php index 60fd8ded5..55091ab5f 100644 --- a/tests/Feature/Providers/Gemini/ToolCallLoopTest.php +++ b/tests/Feature/Providers/Gemini/ToolCallLoopTest.php @@ -2,11 +2,12 @@ use Illuminate\Support\Facades\Http; use Laravel\Ai\Exceptions\NoSuchToolException; +use Laravel\Ai\Responses\AgentResponse; use Tests\Fixtures\Agents\MultiStepToolAgent; use Tests\Fixtures\Agents\NamedToolAgent; use Tests\Fixtures\Agents\ToolUsingAgent; -test('tool calls trigger follow up request', function () { +test('tool calls trigger follow up request', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ $this->fakeUniqueToolCallResponse(), @@ -50,7 +51,7 @@ ->and($hasFunctionResponse)->toBeTrue('Follow-up request should include user message with functionResponse'); }); -test('max steps limits tool call depth', function () { +test('max steps limits tool call depth', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ $this->fakeUniqueToolCallResponse(), @@ -72,7 +73,7 @@ expect(count($recorded))->toBeLessThanOrEqual(3); }); -test('multi step tool loop returns accumulated response shape', function () { +test('multi step tool loop returns accumulated response shape', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ $this->fakeUniqueToolCallResponse(), @@ -95,7 +96,7 @@ ->and($response->usage->completionTokens)->toBe(15); }); -test('unregistered tool call throws', function () { +test('unregistered tool call throws', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ $this->fakeToolCallResponse('NonExistentTool', 'call_missing'), @@ -103,11 +104,11 @@ ]), ]); - expect(fn () => (new ToolUsingAgent(fixed: true))->prompt('Generate', provider: 'gemini')) + expect(fn (): AgentResponse => (new ToolUsingAgent(fixed: true))->prompt('Generate', provider: 'gemini')) ->toThrow(NoSuchToolException::class); }); -test('function response includes id for gemini 3', function () { +test('function response includes id for gemini 3', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ $this->fakeToolCallResponse('FixedNumberGenerator', 'call_abc123'), @@ -139,7 +140,7 @@ ->and($functionResponsePart['response'])->toHaveKeys(['name', 'content']); }); -test('parallel function calls preserve unique ids', function () { +test('parallel function calls preserve unique ids', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ Http::response([ @@ -182,7 +183,7 @@ ->toContain('call_2'); }); -test('tool declaring a name() method routes the function call back to itself', function () { +test('tool declaring a name() method routes the function call back to itself', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ $this->fakeToolCallResponse('aliased_tool', 'call_named_1'), @@ -211,7 +212,7 @@ ->and($functionResponsePart['name'])->toBe('aliased_tool'); }); -test('thinking parts are excluded from tool call continuation', function () { +test('thinking parts are excluded from tool call continuation', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ Http::response([ diff --git a/tests/Feature/Providers/Gemini/ToolMappingTest.php b/tests/Feature/Providers/Gemini/ToolMappingTest.php index fec6e5bbc..9a8a43774 100644 --- a/tests/Feature/Providers/Gemini/ToolMappingTest.php +++ b/tests/Feature/Providers/Gemini/ToolMappingTest.php @@ -11,7 +11,7 @@ use function Laravel\Ai\agent; -test('empty schema omits parameters key', function () { +test('empty schema omits parameters key', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('The number is 42'), ]); @@ -21,7 +21,7 @@ provider: 'gemini', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $tools = $request->data()['tools'] ?? []; foreach ($tools as $toolGroup) { @@ -36,7 +36,7 @@ }); }); -test('tool parameters exclude additional properties', function () { +test('tool parameters exclude additional properties', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('The number is 42'), ]); @@ -46,7 +46,7 @@ provider: 'gemini', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $tools = $request->data()['tools'] ?? []; foreach ($tools as $toolGroup) { @@ -62,14 +62,14 @@ }); }); -test('nested object parameters recursively exclude additional properties', function () { +test('nested object parameters recursively exclude additional properties', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('ok'), ]); (new NestedObjectToolAgent)->prompt('Test nested params', provider: 'gemini'); - $hasAdditionalProperties = function ($node) use (&$hasAdditionalProperties) { + $hasAdditionalProperties = function ($node) use (&$hasAdditionalProperties): bool { if (! is_array($node)) { return false; } @@ -87,7 +87,7 @@ return false; }; - Http::assertSent(function ($request) use ($hasAdditionalProperties) { + Http::assertSent(function ($request) use ($hasAdditionalProperties): bool { $params = $request->data()['tools'][0]['function_declarations'][0]['parameters']; // The nested object lives under the array's items and must survive the strip. @@ -98,14 +98,14 @@ }); }); -test('nullable tool parameters use OpenAPI-style nullable format', function () { +test('nullable tool parameters use OpenAPI-style nullable format', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('ok'), ]); (new NullableToolAgent)->prompt('Test nullable params', provider: 'gemini'); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $props = $request->data()['tools'][0]['function_declarations'][0]['parameters']['properties']; return $props['name'] === ['type' => 'string'] @@ -114,14 +114,14 @@ }); }); -test('tool with a name() method emits the declared name', function () { +test('tool with a name() method emits the declared name', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('ok'), ]); (new NamedToolAgent('aliased_tool'))->prompt('Search', provider: 'gemini'); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $names = []; foreach ($request->data()['tools'] ?? [] as $toolGroup) { @@ -134,14 +134,14 @@ }); }); -test('tool without a name() method falls back to class basename', function () { +test('tool without a name() method falls back to class basename', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('ok'), ]); (new ToolUsingAgent(fixed: true))->prompt('Generate', provider: 'gemini'); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $names = []; foreach ($request->data()['tools'] ?? [] as $toolGroup) { @@ -154,7 +154,7 @@ }); }); -test('provider tools are sent without function_calling_config', function () { +test('provider tools are sent without function_calling_config', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('ok'), ]); @@ -164,7 +164,7 @@ tools: [new FileSearch(['fileSearchStores/store123'])], )->prompt('Question?', provider: 'gemini'); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); $tools = $body['tools'] ?? []; @@ -174,7 +174,7 @@ }); }); -test('mixed function and provider tools are sent without tool_config', function () { +test('mixed function and provider tools are sent without tool_config', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('ok'), ]); @@ -184,12 +184,12 @@ tools: [new FixedNumberGenerator, new WebSearch], )->prompt('Generate', provider: 'gemini'); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $body = $request->data(); $tools = $body['tools'] ?? []; - $hasFunctionDeclarations = collect($tools)->contains(fn ($tool) => isset($tool['function_declarations'])); - $hasGoogleSearch = collect($tools)->contains(fn ($tool) => isset($tool['google_search'])); + $hasFunctionDeclarations = collect($tools)->contains(fn ($tool): bool => isset($tool['function_declarations'])); + $hasGoogleSearch = collect($tools)->contains(fn ($tool): bool => isset($tool['google_search'])); return $hasFunctionDeclarations && $hasGoogleSearch @@ -197,7 +197,7 @@ }); }); -test('tools are wrapped in function declarations', function () { +test('tools are wrapped in function declarations', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => $this->fakeTextResponse('The number is 42'), ]); @@ -207,7 +207,7 @@ provider: 'gemini', ); - Http::assertSent(function ($request) { + Http::assertSent(function ($request): bool { $tools = $request->data()['tools'] ?? []; if (! isset($tools[0]['function_declarations']) || count($tools[0]['function_declarations']) === 0) { diff --git a/tests/Feature/Providers/Gemini/TranscriptionTest.php b/tests/Feature/Providers/Gemini/TranscriptionTest.php index 5bd1c9d18..626665a46 100644 --- a/tests/Feature/Providers/Gemini/TranscriptionTest.php +++ b/tests/Feature/Providers/Gemini/TranscriptionTest.php @@ -5,22 +5,22 @@ use Illuminate\Support\Facades\Http; use Laravel\Ai\Transcription; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.gemini' => [ ...config('ai.providers.gemini'), 'key' => 'test-key', ]]); }); -test('transcription request sends audio as inline data with correct mime type', function () { +test('transcription request sends audio as inline data with correct mime type', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiTranscriptionResponse(), ]); - Transcription::of(base64_encode('fake-audio'), 'audio/mp3') + Transcription::of(base64_encode('fake-audio')) ->generate(provider: 'gemini', model: 'gemini-3.5-flash'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = $request->data(); $parts = $body['contents'][0]['parts']; @@ -30,27 +30,27 @@ }); }); -test('transcription request includes language in prompt when specified', function () { +test('transcription request includes language in prompt when specified', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiTranscriptionResponse(), ]); - Transcription::of(base64_encode('fake-audio'), 'audio/mp3') + Transcription::of(base64_encode('fake-audio')) ->language('fr') ->generate(provider: 'gemini', model: 'gemini-3.5-flash'); - Http::assertSent(fn (Request $request) => str_contains( - $request->data()['contents'][0]['parts'][0]['text'], + Http::assertSent(fn (Request $request): bool => str_contains( + (string) $request->data()['contents'][0]['parts'][0]['text'], 'fr' )); }); -test('transcription response returns text with correct meta', function () { +test('transcription response returns text with correct meta', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiTranscriptionResponse(), ]); - $response = Transcription::of(base64_encode('fake-audio'), 'audio/mp3') + $response = Transcription::of(base64_encode('fake-audio')) ->generate(provider: 'gemini', model: 'gemini-3.5-flash'); expect($response->text)->toBe('Hello world') @@ -61,41 +61,41 @@ ->and($response->usage->completionTokens)->toBe(5); }); -test('transcription uses default model when none specified', function () { +test('transcription uses default model when none specified', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiTranscriptionResponse(), ]); - Transcription::of(base64_encode('fake-audio'), 'audio/mp3')->generate(provider: 'gemini'); + Transcription::of(base64_encode('fake-audio'))->generate(provider: 'gemini'); - Http::assertSent(fn (Request $request) => str_contains($request->url(), 'models/gemini-3.5-flash:generateContent')); + Http::assertSent(fn (Request $request): bool => str_contains($request->url(), 'models/gemini-3.5-flash:generateContent')); }); -test('diarized transcription request sends json schema in generation config', function () { +test('diarized transcription request sends json schema in generation config', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiDiarizedTranscriptionResponse(), ]); - Transcription::of(base64_encode('fake-audio'), 'audio/mp3') + Transcription::of(base64_encode('fake-audio')) ->diarize() ->generate(provider: 'gemini', model: 'gemini-3.5-flash'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = $request->data(); return isset($body['generationConfig']['responseMimeType']) && $body['generationConfig']['responseMimeType'] === 'application/json' && isset($body['generationConfig']['responseSchema']['properties']['segments']) - && str_contains($body['contents'][0]['parts'][0]['text'], 'MM:SS or HH:MM:SS'); + && str_contains((string) $body['contents'][0]['parts'][0]['text'], 'MM:SS or HH:MM:SS'); }); }); -test('diarized transcription response returns text and segments', function () { +test('diarized transcription response returns text and segments', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiDiarizedTranscriptionResponse(), ]); - $response = Transcription::of(base64_encode('fake-audio'), 'audio/mp3') + $response = Transcription::of(base64_encode('fake-audio')) ->diarize() ->generate(provider: 'gemini', model: 'gemini-3.5-flash'); @@ -111,7 +111,7 @@ ->and($response->usage->completionTokens)->toBe(20); }); -test('diarized transcription parses srt and hour timestamp formats', function () { +test('diarized transcription parses srt and hour timestamp formats', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => fakeGeminiDiarizedTranscriptionResponse([ ['text' => 'Hello', 'start_time' => '00:00:01,500', 'end_time' => '00:01:02,250'], @@ -119,7 +119,7 @@ ]), ]); - $response = Transcription::of(base64_encode('fake-audio'), 'audio/mp3') + $response = Transcription::of(base64_encode('fake-audio')) ->diarize() ->generate(provider: 'gemini', model: 'gemini-3.5-flash'); diff --git a/tests/Feature/Providers/Groq/AgentFakeTest.php b/tests/Feature/Providers/Groq/AgentFakeTest.php index 1f6f0873a..12c01e242 100644 --- a/tests/Feature/Providers/Groq/AgentFakeTest.php +++ b/tests/Feature/Providers/Groq/AgentFakeTest.php @@ -2,7 +2,7 @@ use Tests\Fixtures\Agents\GroqAgent; -test('groq agent can be faked', function () { +test('groq agent can be faked', function (): void { GroqAgent::fake(['Test response']); $response = (new GroqAgent)->prompt('Hello'); @@ -10,15 +10,15 @@ expect($response->text)->toBe('Test response'); }); -test('groq agent fake with closure', function () { - GroqAgent::fake(fn (string $prompt) => "Echo: {$prompt}"); +test('groq agent fake with closure', function (): void { + GroqAgent::fake(fn (string $prompt): string => "Echo: {$prompt}"); $response = (new GroqAgent)->prompt('Hello world'); expect($response->text)->toBe('Echo: Hello world'); }); -test('groq agent fake with no predefined responses', function () { +test('groq agent fake with no predefined responses', function (): void { GroqAgent::fake(); $response = (new GroqAgent)->prompt('Hello'); @@ -26,7 +26,7 @@ expect($response->text)->toBe('Fake response for prompt: Hello'); }); -test('groq agent fake records prompts', function () { +test('groq agent fake records prompts', function (): void { GroqAgent::fake(); (new GroqAgent)->prompt('Hello'); @@ -35,11 +35,11 @@ GroqAgent::assertNotPrompted('Goodbye'); }); -test('groq agent stream can be faked', function () { +test('groq agent stream can be faked', function (): void { GroqAgent::fake(['Streamed response']); $response = (new GroqAgent)->stream('Hello'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->text)->toBe('Streamed response'); }); diff --git a/tests/Feature/Providers/Groq/BaseUrlTest.php b/tests/Feature/Providers/Groq/BaseUrlTest.php index 8f7acbc85..521983b14 100644 --- a/tests/Feature/Providers/Groq/BaseUrlTest.php +++ b/tests/Feature/Providers/Groq/BaseUrlTest.php @@ -5,11 +5,11 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { $this->customUrl = 'http://localhost:1234/v1'; }); -test('groq text requests use the configured base url', function () { +test('groq text requests use the configured base url', function (): void { configureGroqProvider($this->customUrl); Http::fake([ @@ -40,7 +40,7 @@ groqAssertRequestSent('POST', "{$this->customUrl}/chat/completions"); }); -test('groq requests fall back to the default base url', function () { +test('groq requests fall back to the default base url', function (): void { configureGroqProvider(); Http::fake([ @@ -82,6 +82,6 @@ function configureGroqProvider(?string $url = null): void function groqAssertRequestSent(string $method, string $url): void { - Http::assertSent(fn (Request $request) => $request->method() === $method + Http::assertSent(fn (Request $request): bool => $request->method() === $method && $request->url() === $url); } diff --git a/tests/Feature/Providers/Groq/ErrorHandlingTest.php b/tests/Feature/Providers/Groq/ErrorHandlingTest.php index dd2ef0462..3086d83ea 100644 --- a/tests/Feature/Providers/Groq/ErrorHandlingTest.php +++ b/tests/Feature/Providers/Groq/ErrorHandlingTest.php @@ -7,14 +7,14 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Tests\Fixtures\Agents\AssistantAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.groq' => [ ...config('ai.providers.groq'), 'key' => 'test-key', ]]); }); -test('http error response throws request exception', function () { +test('http error response throws request exception', function (): void { Http::fake([ 'api.groq.com/*' => Http::response([ 'error' => [ @@ -30,7 +30,7 @@ ); })->throws(RequestException::class); -test('rate limit response throws rate limited exception', function () { +test('rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.groq.com/*' => Http::response([ 'error' => [ @@ -46,7 +46,7 @@ ); })->throws(RateLimitedException::class); -test('overloaded response throws provider overloaded exception', function () { +test('overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.groq.com/*' => Http::response([ 'error' => [ @@ -62,7 +62,7 @@ ); })->throws(ProviderOverloadedException::class); -test('error in 200 response throws ai exception', function () { +test('error in 200 response throws ai exception', function (): void { Http::fake([ 'api.groq.com/*' => Http::response([ 'error' => [ diff --git a/tests/Feature/Providers/Groq/GroqHelpers.php b/tests/Feature/Providers/Groq/GroqHelpers.php index 29a607ba9..e19eb591c 100644 --- a/tests/Feature/Providers/Groq/GroqHelpers.php +++ b/tests/Feature/Providers/Groq/GroqHelpers.php @@ -29,11 +29,7 @@ protected function ssePayload(array $events): string $lines = []; foreach ($events as $event) { - if ($event === '[DONE]') { - $lines[] = 'data: [DONE]'; - } else { - $lines[] = 'data: '.json_encode($event); - } + $lines[] = $event === '[DONE]' ? 'data: [DONE]' : 'data: '.json_encode($event); } return implode("\n\n", $lines)."\n\n"; diff --git a/tests/Feature/Providers/Groq/MessageMappingTest.php b/tests/Feature/Providers/Groq/MessageMappingTest.php index 3c979805c..f4c8db13d 100644 --- a/tests/Feature/Providers/Groq/MessageMappingTest.php +++ b/tests/Feature/Providers/Groq/MessageMappingTest.php @@ -10,14 +10,14 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.groq' => [ ...config('ai.providers.groq'), 'key' => 'test-key', ]]); }); -test('user message maps to groq format', function () { +test('user message maps to groq format', function (): void { Http::fake([ 'api.groq.com/*' => fakeGroqResponse(), ]); @@ -27,7 +27,7 @@ provider: 'groq', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $messages = $body['messages']; $userMessage = collect($messages)->firstWhere('role', 'user'); @@ -37,7 +37,7 @@ }); }); -test('tool result follow up maps assistant and tool result messages', function () { +test('tool result follow up maps assistant and tool result messages', function (): void { Http::fake([ 'api.groq.com/*' => Http::sequence([ fakeGroqToolCallResponse(), @@ -54,7 +54,7 @@ expect($recorded)->toHaveCount(2); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); $followUpMessages = $followUpBody['messages']; $hasAssistantWithToolCalls = false; @@ -73,15 +73,15 @@ expect($hasAssistantWithToolCalls)->toBeTrue() ->and($hasToolResult)->toBeTrue(); - $assistantMsg = collect($followUpMessages)->last(fn ($m) => $m['role'] === 'assistant' && isset($m['tool_calls'])); - $toolMsg = collect($followUpMessages)->last(fn ($m) => $m['role'] === 'tool'); + $assistantMsg = collect($followUpMessages)->last(fn ($m): bool => $m['role'] === 'assistant' && isset($m['tool_calls'])); + $toolMsg = collect($followUpMessages)->last(fn ($m): bool => $m['role'] === 'tool'); expect($assistantMsg['tool_calls'][0]['function']['name'])->toBe('FixedNumberGenerator') ->and($toolMsg['tool_call_id'])->toBe($assistantMsg['tool_calls'][0]['id']) ->and($toolMsg['content'])->not->toBeEmpty(); }); -test('image attachment maps to image url content block', function () { +test('image attachment maps to image url content block', function (): void { Http::fake([ 'api.groq.com/*' => fakeGroqResponse('I see an image'), ]); @@ -94,7 +94,7 @@ provider: 'groq', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['messages'])->firstWhere('role', 'user'); $content = $userMessage['content']; @@ -102,12 +102,12 @@ $imageBlock = collect($content)->firstWhere('type', 'image_url'); return $imageBlock !== null - && str_contains($imageBlock['image_url']['url'], 'image/png') - && str_contains($imageBlock['image_url']['url'], base64_encode('fake-image-data')); + && str_contains((string) $imageBlock['image_url']['url'], 'image/png') + && str_contains((string) $imageBlock['image_url']['url'], base64_encode('fake-image-data')); }); }); -test('local image attachment without explicit mime type detects mime from file', function () { +test('local image attachment without explicit mime type detects mime from file', function (): void { Http::fake([ 'api.groq.com/*' => fakeGroqResponse('I see an image'), ]); @@ -118,18 +118,18 @@ provider: 'groq', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['messages'])->firstWhere('role', 'user'); $imageBlock = collect($userMessage['content'])->firstWhere('type', 'image_url'); return $imageBlock !== null - && str_starts_with($imageBlock['image_url']['url'], 'data:image/png;base64,') - && ! str_contains($imageBlock['image_url']['url'], 'data:;base64,'); + && str_starts_with((string) $imageBlock['image_url']['url'], 'data:image/png;base64,') + && ! str_contains((string) $imageBlock['image_url']['url'], 'data:;base64,'); }); }); -test('document attachments throw exception', function () { +test('document attachments throw exception', function (): void { Http::fake([ 'api.groq.com/*' => fakeGroqResponse(), ]); diff --git a/tests/Feature/Providers/Groq/ProviderOptionsTest.php b/tests/Feature/Providers/Groq/ProviderOptionsTest.php index 8a73ce303..4836caa30 100644 --- a/tests/Feature/Providers/Groq/ProviderOptionsTest.php +++ b/tests/Feature/Providers/Groq/ProviderOptionsTest.php @@ -8,21 +8,21 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.groq' => [ ...config('ai.providers.groq'), 'key' => 'test-key', ]]); }); -test('provider options are included in groq request body', function () { +test('provider options are included in groq request body', function (): void { Http::fake([ '*' => fakeGroqResponse('Hello'), ]); (new ProviderOptionsAgent)->prompt('Hello', provider: 'groq'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'frequency_penalty') === 0.5 @@ -30,14 +30,14 @@ }); }); -test('request body does not contain provider options when agent does not implement interface', function () { +test('request body does not contain provider options when agent does not implement interface', function (): void { Http::fake([ '*' => fakeGroqResponse('Hello'), ]); agent()->prompt('Hello', provider: 'groq'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('reasoning', $body) @@ -46,7 +46,7 @@ }); }); -test('provider options are persisted in tool call follow up requests', function () { +test('provider options are persisted in tool call follow up requests', function (): void { Http::fake([ '*' => Http::sequence([ fakeGroqToolCallResponse(), @@ -56,11 +56,11 @@ (new ProviderOptionsWithToolsAgent)->prompt('Give me a number', provider: 'groq'); - $requests = Http::recorded(fn (Request $r) => true); + $requests = Http::recorded(fn (Request $r): true => true); expect(count($requests))->toBeGreaterThanOrEqual(2); - $followUpBody = json_decode($requests[1][0]->body(), true); + $followUpBody = json_decode((string) $requests[1][0]->body(), true); expect(data_get($followUpBody, 'frequency_penalty'))->toBe(0.5); }); diff --git a/tests/Feature/Providers/Groq/RequestMappingTest.php b/tests/Feature/Providers/Groq/RequestMappingTest.php index 19603a1a8..e3db39915 100644 --- a/tests/Feature/Providers/Groq/RequestMappingTest.php +++ b/tests/Feature/Providers/Groq/RequestMappingTest.php @@ -11,47 +11,47 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.groq' => [ ...config('ai.providers.groq'), 'key' => 'test-key', ]]); }); -test('request includes model and messages', function () { +test('request includes model and messages', function (): void { Http::fake(['*' => fakeGroqResponse('Hello')]); agent()->prompt('Hi there', provider: 'groq', model: 'llama-3.3-70b-versatile'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'llama-3.3-70b-versatile' && count($body['messages']) >= 1 - && collect($body['messages'])->contains(fn ($m) => $m['role'] === 'user' && $m['content'] === 'Hi there'); + && collect($body['messages'])->contains(fn ($m): bool => $m['role'] === 'user' && $m['content'] === 'Hi there'); }); }); -test('system instructions are sent as system message', function () { +test('system instructions are sent as system message', function (): void { Http::fake(['*' => fakeGroqResponse('Hello')]); (new AssistantAgent)->prompt('Hello', provider: 'groq'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $systemMsg = collect($body['messages'])->firstWhere('role', 'system'); return $systemMsg !== null - && str_contains($systemMsg['content'], 'helpful assistant'); + && str_contains((string) $systemMsg['content'], 'helpful assistant'); }); }); -test('temperature and max tokens are included when set via attributes', function () { +test('temperature and max tokens are included when set via attributes', function (): void { Http::fake(['*' => fakeGroqResponse('Hello')]); (new AttributeAgent)->prompt('Hello', provider: 'groq'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'temperature') === 0.7 @@ -59,12 +59,12 @@ }); }); -test('temperature and max tokens are excluded when not set', function () { +test('temperature and max tokens are excluded when not set', function (): void { Http::fake(['*' => fakeGroqResponse('Hello')]); agent()->prompt('Hello', provider: 'groq'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('temperature', $body) @@ -72,26 +72,26 @@ }); }); -test('tools include tool choice auto', function () { +test('tools include tool choice auto', function (): void { Http::fake(['*' => fakeGroqResponse('42')]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a number', provider: 'groq'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['tool_choice'] === 'auto' && is_array($body['tools']) - && count($body['tools']) > 0; + && $body['tools'] !== []; }); }); -test('request without tools excludes tool fields', function () { +test('request without tools excludes tool fields', function (): void { Http::fake(['*' => fakeGroqResponse('Hello')]); agent()->prompt('Hello', provider: 'groq'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('tools', $body) @@ -99,49 +99,47 @@ }); }); -test('required tool choice forces the model to call a tool', function () { +test('required tool choice forces the model to call a tool', function (): void { Http::fake(['*' => fakeGroqResponse('42')]); (new ToolChoiceAgent('required'))->prompt('Give me a number', provider: 'groq'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'required'); }); -test('required tool choice can be set via attribute', function () { +test('required tool choice can be set via attribute', function (): void { Http::fake(['*' => fakeGroqResponse('42')]); (new AttributeToolChoiceAgent)->prompt('Give me a number', provider: 'groq'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'required'); }); -test('named tool choice forces a specific function', function () { +test('named tool choice forces a specific function', function (): void { Http::fake(['*' => fakeGroqResponse('42')]); (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Give me a number', provider: 'groq'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['tool_choice'] === [ - 'type' => 'function', - 'function' => ['name' => 'custom_named_tool'], - ]; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === [ + 'type' => 'function', + 'function' => ['name' => 'custom_named_tool'], + ]); }); -test('none tool choice prevents tool calls', function () { +test('none tool choice prevents tool calls', function (): void { Http::fake(['*' => fakeGroqResponse('Sure')]); (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'groq'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'none'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'none'); }); -test('structured output includes json schema response format', function () { +test('structured output includes json schema response format', function (): void { Http::fake(['*' => fakeGroqResponse('{"symbol": "Au"}')]); (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'groq'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $format = data_get($body, 'response_format'); @@ -152,38 +150,38 @@ }); }); -test('request without schema excludes response format', function () { +test('request without schema excludes response format', function (): void { Http::fake(['*' => fakeGroqResponse('Hello')]); agent()->prompt('Hello', provider: 'groq'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('response_format', $body); }); }); -test('schema combined with tools omits response format but keeps schema instructions', function () { +test('schema combined with tools omits response format but keeps schema instructions', function (): void { Http::fake(['*' => fakeGroqResponse('{"number": 42}')]); agent( tools: [new RandomNumberGenerator], - schema: fn ($s) => ['number' => $s->integer()->required()], + schema: fn ($s): array => ['number' => $s->integer()->required()], )->prompt('Give me a number', provider: 'groq'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $systemMsg = collect($body['messages'])->firstWhere('role', 'system'); return ! array_key_exists('response_format', $body) && is_array($body['tools']) && $systemMsg !== null - && str_contains($systemMsg['content'], 'JSON object that strictly adheres'); + && str_contains((string) $systemMsg['content'], 'JSON object that strictly adheres'); }); }); -test('streaming request includes stream options', function () { +test('streaming request includes stream options', function (): void { Http::fake(['*' => Http::response("data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hi\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1}}\n\ndata: [DONE]\n\n")]); $stream = agent()->stream('Hello', provider: 'groq'); @@ -193,7 +191,7 @@ // } - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['stream'] === true @@ -201,17 +199,15 @@ }); }); -test('request sends bearer token authorization', function () { +test('request sends bearer token authorization', function (): void { Http::fake(['*' => fakeGroqResponse('Hello')]); agent()->prompt('Hello', provider: 'groq'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('response text is correctly parsed', function () { +test('response text is correctly parsed', function (): void { Http::fake(['*' => fakeGroqResponse('Laravel is great')]); $response = agent()->prompt('Tell me about Laravel', provider: 'groq'); @@ -220,7 +216,7 @@ ->and($response->meta->provider)->toBe('groq'); }); -test('response usage is correctly parsed', function () { +test('response usage is correctly parsed', function (): void { Http::fake(['*' => Http::response([ 'id' => 'chatcmpl-123', 'object' => 'chat.completion', @@ -242,7 +238,7 @@ ->and($response->usage->completionTokens)->toBe(5); }); -test('response usage includes reasoning tokens', function () { +test('response usage includes reasoning tokens', function (): void { Http::fake(['*' => Http::response([ 'id' => 'chatcmpl-r1-1', 'object' => 'chat.completion', @@ -269,7 +265,7 @@ ->and($response->usage->reasoningTokens)->toBe(20); }); -test('response usage includes cached prompt tokens', function () { +test('response usage includes cached prompt tokens', function (): void { Http::fake(['*' => Http::response([ 'id' => 'chatcmpl-cache-1', 'object' => 'chat.completion', @@ -296,7 +292,7 @@ ->and($response->usage->cacheReadInputTokens)->toBe(4608); }); -test('structured response is correctly parsed', function () { +test('structured response is correctly parsed', function (): void { Http::fake(['*' => fakeGroqResponse('{"symbol": "Au"}')]); $response = (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'groq'); diff --git a/tests/Feature/Providers/Groq/StreamingTest.php b/tests/Feature/Providers/Groq/StreamingTest.php index 453abe2cf..354f9db3b 100644 --- a/tests/Feature/Providers/Groq/StreamingTest.php +++ b/tests/Feature/Providers/Groq/StreamingTest.php @@ -11,14 +11,14 @@ use Laravel\Ai\Streaming\Events\ToolCall as ToolCallEvent; use Tests\Fixtures\Agents\ProviderOptionsWithToolsAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.groq' => [ ...config('ai.providers.groq'), 'key' => 'test-key', ]]); }); -test('streaming emits text events', function () { +test('streaming emits text events', function (): void { Http::fake([ 'api.groq.com/*' => Http::response( body: $this->ssePayload([ @@ -42,7 +42,7 @@ ->and($events[count($events) - 1])->toBeInstanceOf(StreamEnd::class); }); -test('streaming handles tool calls', function () { +test('streaming handles tool calls', function (): void { Http::fake([ 'api.groq.com/*' => Http::sequence([ Http::response( @@ -69,14 +69,14 @@ $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); - $toolCallEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolCallEvent)); + $toolCallEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolCallEvent)); expect($toolCallEvents)->not->toBeEmpty() ->and($toolCallEvents[0]->toolCall->name)->toBe('FixedNumberGenerator') ->and($toolCallEvents[0]->toolCall->id)->toBe('call_1'); }); -test('streaming tool call loop emits a single accumulated stream end', function () { +test('streaming tool call loop emits a single accumulated stream end', function (): void { Http::fake([ 'api.groq.com/*' => Http::sequence([ Http::response( @@ -103,7 +103,7 @@ $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); - $streamEnds = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd)); + $streamEnds = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd)); expect($streamEnds)->toHaveCount(1) ->and($streamEnds[0]->reason)->toBe(FinishReason::Stop->value) @@ -111,7 +111,7 @@ ->and($streamEnds[0]->usage->completionTokens)->toBe(15); }); -test('streaming error event stops stream', function () { +test('streaming error event stops stream', function (): void { Http::fake([ 'api.groq.com/*' => Http::response( body: $this->ssePayload([ @@ -130,7 +130,7 @@ ->and($events[0]->message)->toBe('Rate limit exceeded'); }); -test('streaming captures usage from final chunk', function () { +test('streaming captures usage from final chunk', function (): void { Http::fake([ 'api.groq.com/*' => Http::response( body: $this->ssePayload([ @@ -145,13 +145,13 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->usage->promptTokens)->toBe(42) ->and($streamEnd->usage->completionTokens)->toBe(10); }); -test('streaming captures reasoning tokens', function () { +test('streaming captures reasoning tokens', function (): void { Http::fake([ 'api.groq.com/*' => Http::response( body: $this->ssePayload([ @@ -173,14 +173,14 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->usage->promptTokens)->toBe(100) ->and($streamEnd->usage->completionTokens)->toBe(50) ->and($streamEnd->usage->reasoningTokens)->toBe(20); }); -test('streaming finish reason maps correctly', function (string $apiReason, $expected) { +test('streaming finish reason maps correctly', function (string $apiReason, $expected): void { Http::fake([ 'api.groq.com/*' => Http::response( body: $this->ssePayload([ @@ -195,7 +195,7 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->reason)->toBe($expected->value); })->with([ diff --git a/tests/Feature/Providers/Groq/ToolCallLoopTest.php b/tests/Feature/Providers/Groq/ToolCallLoopTest.php index 7d61b34f6..5ed2db044 100644 --- a/tests/Feature/Providers/Groq/ToolCallLoopTest.php +++ b/tests/Feature/Providers/Groq/ToolCallLoopTest.php @@ -3,17 +3,18 @@ use GuzzleHttp\Promise\PromiseInterface; use Illuminate\Support\Facades\Http; use Laravel\Ai\Exceptions\NoSuchToolException; +use Laravel\Ai\Responses\AgentResponse; use Tests\Fixtures\Agents\MultiStepToolAgent; use Tests\Fixtures\Agents\ToolUsingAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.groq' => [ ...config('ai.providers.groq'), 'key' => 'test-key', ]]); }); -test('tool calls trigger follow up request', function () { +test('tool calls trigger follow up request', function (): void { Http::fake([ 'api.groq.com/*' => Http::sequence([ fakeUniqueGroqToolCallResponse(), @@ -30,7 +31,7 @@ expect($recorded)->toHaveCount(2); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); $hasAssistantWithToolCalls = false; $hasToolResult = false; @@ -49,7 +50,7 @@ ->and($hasToolResult)->toBeTrue(); }); -test('max steps limits tool call depth', function () { +test('max steps limits tool call depth', function (): void { Http::fake([ 'api.groq.com/*' => Http::sequence([ fakeUniqueGroqToolCallResponse(), @@ -69,7 +70,7 @@ expect(count($recorded))->toBeLessThanOrEqual(3); }); -test('multi step tool loop returns accumulated response shape', function () { +test('multi step tool loop returns accumulated response shape', function (): void { Http::fake([ 'api.groq.com/*' => Http::sequence([ fakeUniqueGroqToolCallResponse(), @@ -92,7 +93,7 @@ ->and($response->usage->completionTokens)->toBe(11); }); -test('unknown tool call throws', function () { +test('unknown tool call throws', function (): void { Http::fake([ 'api.groq.com/*' => Http::response([ 'id' => 'chatcmpl-missing', @@ -121,7 +122,7 @@ ]), ]); - expect(fn () => (new MultiStepToolAgent)->prompt( + expect(fn (): AgentResponse => (new MultiStepToolAgent)->prompt( 'Generate numbers', provider: 'groq', ))->toThrow(NoSuchToolException::class); diff --git a/tests/Feature/Providers/Groq/ToolMappingTest.php b/tests/Feature/Providers/Groq/ToolMappingTest.php index 1d6d81fea..63aa09ba3 100644 --- a/tests/Feature/Providers/Groq/ToolMappingTest.php +++ b/tests/Feature/Providers/Groq/ToolMappingTest.php @@ -8,21 +8,21 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.groq' => [ ...config('ai.providers.groq'), 'key' => 'test-key', ]]); }); -test('tool with parameters includes correct schema', function () { +test('tool with parameters includes correct schema', function (): void { Http::fake([ '*' => fakeGroqResponse('42'), ]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a random number', provider: 'groq'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; @@ -36,14 +36,14 @@ }); }); -test('tool with empty schema includes parameters', function () { +test('tool with empty schema includes parameters', function (): void { Http::fake([ '*' => fakeGroqResponse('72019'), ]); agent(tools: [new FixedNumberGenerator])->prompt('Give me a random number', provider: 'groq'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; @@ -56,14 +56,14 @@ }); }); -test('tool with a name() method emits the declared name', function () { +test('tool with a name() method emits the declared name', function (): void { Http::fake([ '*' => fakeGroqResponse('ok'), ]); agent(tools: [new NamedTool('my_custom_tool')])->prompt('Hi', provider: 'groq'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $names = collect(data_get($body, 'tools'))->pluck('function.name')->all(); @@ -71,14 +71,14 @@ }); }); -test('tool parameters are not wrapped in schema definition', function () { +test('tool parameters are not wrapped in schema definition', function (): void { Http::fake([ '*' => fakeGroqResponse('done'), ]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a random number', provider: 'groq'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; diff --git a/tests/Feature/Providers/Jina/BaseUrlTest.php b/tests/Feature/Providers/Jina/BaseUrlTest.php index 187833c1c..d5668d820 100644 --- a/tests/Feature/Providers/Jina/BaseUrlTest.php +++ b/tests/Feature/Providers/Jina/BaseUrlTest.php @@ -23,7 +23,7 @@ function fakeJinaBaseUrlRerankingResponse(): PromiseInterface ]); } -test('jina embedding requests use the configured base url', function () { +test('jina embedding requests use the configured base url', function (): void { config(['ai.providers.jina' => [ ...config('ai.providers.jina'), 'key' => 'test-key', @@ -34,10 +34,10 @@ function fakeJinaBaseUrlRerankingResponse(): PromiseInterface Embeddings::for(['Hello'])->generate(provider: 'jina', model: 'jina-embeddings-v4'); - Http::assertSent(fn (Request $r) => $r->url() === 'http://localhost:8080/v1/embeddings'); + Http::assertSent(fn (Request $r): bool => $r->url() === 'http://localhost:8080/v1/embeddings'); }); -test('jina reranking requests use the configured base url', function () { +test('jina reranking requests use the configured base url', function (): void { config(['ai.providers.jina' => [ ...config('ai.providers.jina'), 'key' => 'test-key', @@ -48,10 +48,10 @@ function fakeJinaBaseUrlRerankingResponse(): PromiseInterface Reranking::of(['doc1'])->rerank('What is AI?', provider: 'jina', model: 'jina-reranker-v3'); - Http::assertSent(fn (Request $r) => $r->url() === 'http://localhost:8080/v1/rerank'); + Http::assertSent(fn (Request $r): bool => $r->url() === 'http://localhost:8080/v1/rerank'); }); -test('jina requests fall back to the default base url', function () { +test('jina requests fall back to the default base url', function (): void { config(['ai.providers.jina' => array_diff_key( [...config('ai.providers.jina'), 'key' => 'test-key'], ['url' => null], @@ -61,5 +61,5 @@ function fakeJinaBaseUrlRerankingResponse(): PromiseInterface Embeddings::for(['Hello'])->generate(provider: 'jina', model: 'jina-embeddings-v4'); - Http::assertSent(fn (Request $r) => $r->url() === 'https://api.jina.ai/v1/embeddings'); + Http::assertSent(fn (Request $r): bool => $r->url() === 'https://api.jina.ai/v1/embeddings'); }); diff --git a/tests/Feature/Providers/Jina/EmbeddingTest.php b/tests/Feature/Providers/Jina/EmbeddingTest.php index afdae8d44..cee8cf506 100644 --- a/tests/Feature/Providers/Jina/EmbeddingTest.php +++ b/tests/Feature/Providers/Jina/EmbeddingTest.php @@ -5,19 +5,19 @@ use Illuminate\Support\Facades\Http; use Laravel\Ai\Embeddings; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.jina' => [ ...config('ai.providers.jina'), 'key' => 'test-key', ]]); }); -test('embeddings request includes model, input, and dimensions', function () { +test('embeddings request includes model, input, and dimensions', function (): void { Http::fake(['*' => fakeJinaEmbeddingsResponse()]); Embeddings::for(['Hello world'])->dimensions(1024)->generate(provider: 'jina', model: 'jina-embeddings-v4'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'jina-embeddings-v4' @@ -28,7 +28,7 @@ }); }); -test('embeddings response is correctly parsed', function () { +test('embeddings response is correctly parsed', function (): void { Http::fake(['*' => fakeJinaEmbeddingsResponse()]); $response = Embeddings::for(['Hello world'])->generate(provider: 'jina', model: 'jina-embeddings-v4'); @@ -40,7 +40,7 @@ ->and($response->meta->model)->toBe('jina-embeddings-v4'); }); -test('embeddings request sends bearer token', function () { +test('embeddings request sends bearer token', function (): void { Http::fake(['*' => fakeJinaEmbeddingsResponse()]); Embeddings::for(['Hello'])->generate(provider: 'jina', model: 'jina-embeddings-v4'); @@ -48,21 +48,21 @@ Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('embeddings use default model when none specified', function () { +test('embeddings use default model when none specified', function (): void { Http::fake(['*' => fakeJinaEmbeddingsResponse()]); Embeddings::for(['Hello'])->generate(provider: 'jina'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['model'] === 'jina-embeddings-v4'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['model'] === 'jina-embeddings-v4'); }); -test('embeddings throw when the API returns an error', function () { +test('embeddings throw when the API returns an error', function (): void { Http::fake(['*' => Http::response(['detail' => 'unauthorized'], 401)]); Embeddings::for(['Hello'])->generate(provider: 'jina', model: 'jina-embeddings-v4'); })->throws(RequestException::class); -test('multiple inputs return multiple embeddings', function () { +test('multiple inputs return multiple embeddings', function (): void { Http::fake(['*' => Http::response([ 'model' => 'jina-embeddings-v4', 'object' => 'list', @@ -79,22 +79,22 @@ ->and($response->embeddings[1])->toBe([0.4, 0.5, 0.6]); }); -test('embeddings default to 2048 dimensions when none specified', function () { +test('embeddings default to 2048 dimensions when none specified', function (): void { Http::fake(['*' => fakeJinaEmbeddingsResponse()]); Embeddings::for(['Hello'])->generate(provider: 'jina', model: 'jina-embeddings-v4'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['dimensions'] === 2048); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['dimensions'] === 2048); }); -test('embeddings request includes provider options and overrides default task', function () { +test('embeddings request includes provider options and overrides default task', function (): void { Http::fake(['*' => fakeJinaEmbeddingsResponse()]); Embeddings::for(['Hello']) ->withProviderOptions(['task' => 'retrieval.query', 'late_chunking' => true]) ->generate(provider: 'jina', model: 'jina-embeddings-v4'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['task'] === 'retrieval.query' diff --git a/tests/Feature/Providers/Jina/ErrorHandlingTest.php b/tests/Feature/Providers/Jina/ErrorHandlingTest.php index 048d27d36..7425d3636 100644 --- a/tests/Feature/Providers/Jina/ErrorHandlingTest.php +++ b/tests/Feature/Providers/Jina/ErrorHandlingTest.php @@ -7,14 +7,14 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Laravel\Ai\Reranking; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.jina' => [ ...config('ai.providers.jina'), 'key' => 'test-key', ]]); }); -test('embeddings rate limit response throws rate limited exception', function () { +test('embeddings rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.jina.ai/*' => Http::response(['detail' => 'Rate limit exceeded'], 429), ]); @@ -22,7 +22,7 @@ Embeddings::for(['Hello'])->generate(provider: 'jina', model: 'jina-embeddings-v4'); })->throws(RateLimitedException::class); -test('embeddings overloaded response throws provider overloaded exception', function () { +test('embeddings overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.jina.ai/*' => Http::response(['detail' => 'Service overloaded'], 503), ]); @@ -30,7 +30,7 @@ Embeddings::for(['Hello'])->generate(provider: 'jina', model: 'jina-embeddings-v4'); })->throws(ProviderOverloadedException::class); -test('embeddings http error response throws request exception', function () { +test('embeddings http error response throws request exception', function (): void { Http::fake([ 'api.jina.ai/*' => Http::response(['detail' => 'Unauthorized'], 401), ]); @@ -38,7 +38,7 @@ Embeddings::for(['Hello'])->generate(provider: 'jina', model: 'jina-embeddings-v4'); })->throws(RequestException::class); -test('reranking rate limit response throws rate limited exception', function () { +test('reranking rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.jina.ai/*' => Http::response(['detail' => 'Rate limit exceeded'], 429), ]); @@ -46,7 +46,7 @@ Reranking::of(['doc1'])->rerank('What is AI?', provider: 'jina', model: 'jina-reranker-v3'); })->throws(RateLimitedException::class); -test('reranking overloaded response throws provider overloaded exception', function () { +test('reranking overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.jina.ai/*' => Http::response(['detail' => 'Service overloaded'], 503), ]); @@ -54,7 +54,7 @@ Reranking::of(['doc1'])->rerank('What is AI?', provider: 'jina', model: 'jina-reranker-v3'); })->throws(ProviderOverloadedException::class); -test('reranking http error response throws request exception', function () { +test('reranking http error response throws request exception', function (): void { Http::fake([ 'api.jina.ai/*' => Http::response(['detail' => 'Unauthorized'], 401), ]); diff --git a/tests/Feature/Providers/Jina/RerankingTest.php b/tests/Feature/Providers/Jina/RerankingTest.php index edf6c78fe..8e96d1afb 100644 --- a/tests/Feature/Providers/Jina/RerankingTest.php +++ b/tests/Feature/Providers/Jina/RerankingTest.php @@ -8,20 +8,20 @@ use Laravel\Ai\Reranking; use Laravel\Ai\Responses\Data\RankedDocument; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.jina' => [ ...config('ai.providers.jina'), 'key' => 'test-key', ]]); }); -test('reranking request includes model, query, and documents', function () { +test('reranking request includes model, query, and documents', function (): void { Http::fake(['*' => fakeJinaRerankingResponse()]); Reranking::of(['Laravel is a PHP framework', 'React is a JS library']) ->rerank('What is Laravel?', provider: 'jina', model: 'jina-reranker-v3'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'jina-reranker-v3' @@ -32,17 +32,17 @@ }); }); -test('reranking request includes top_n when limit set', function () { +test('reranking request includes top_n when limit set', function (): void { Http::fake(['*' => fakeJinaRerankingResponse()]); Reranking::of(['Doc A', 'Doc B', 'Doc C']) ->limit(2) ->rerank('query', provider: 'jina', model: 'jina-reranker-v3'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['top_n'] === 2); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['top_n'] === 2); }); -test('reranking response is correctly parsed into RankedDocuments', function () { +test('reranking response is correctly parsed into RankedDocuments', function (): void { Http::fake(['*' => fakeJinaRerankingResponse()]); $response = Reranking::of(['Laravel is a PHP framework', 'React is a JS library']) @@ -57,7 +57,7 @@ ->and($response->meta->model)->toBe('jina-reranker-v3'); }); -test('reranking request sends bearer token', function () { +test('reranking request sends bearer token', function (): void { Http::fake(['*' => fakeJinaRerankingResponse()]); Reranking::of(['Doc A', 'Doc B'])->rerank('query', provider: 'jina', model: 'jina-reranker-v3'); @@ -65,15 +65,15 @@ Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('reranking uses default model when none specified', function () { +test('reranking uses default model when none specified', function (): void { Http::fake(['*' => fakeJinaRerankingResponse()]); Reranking::of(['Doc A', 'Doc B'])->rerank('query', provider: 'jina'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['model'] === 'jina-reranker-v3'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['model'] === 'jina-reranker-v3'); }); -test('reranking maps documents by index when results are returned out of order', function () { +test('reranking maps documents by index when results are returned out of order', function (): void { Http::fake(['*' => Http::response([ 'results' => [ ['index' => 2, 'relevance_score' => 0.91], @@ -96,19 +96,19 @@ ->and($ranked[1]->document)->toBe('Doc A'); }); -test('reranking throws when the API returns an error', function () { +test('reranking throws when the API returns an error', function (): void { Http::fake(['*' => Http::response(['detail' => 'unauthorized'], 401)]); Reranking::of(['Doc A', 'Doc B'])->rerank('query', provider: 'jina', model: 'jina-reranker-v3'); })->throws(RequestException::class); -test('reranking rate limit response throws rate limited exception', function () { +test('reranking rate limit response throws rate limited exception', function (): void { Http::fake(['api.jina.ai/*' => Http::response(['detail' => 'rate limit exceeded'], 429)]); Reranking::of(['Doc A', 'Doc B'])->rerank('query', provider: 'jina', model: 'jina-reranker-v3'); })->throws(RateLimitedException::class); -test('reranking overloaded response throws provider overloaded exception', function () { +test('reranking overloaded response throws provider overloaded exception', function (): void { Http::fake(['api.jina.ai/*' => Http::response(['detail' => 'service unavailable'], 503)]); Reranking::of(['Doc A', 'Doc B'])->rerank('query', provider: 'jina', model: 'jina-reranker-v3'); diff --git a/tests/Feature/Providers/Mistral/AgentFakeTest.php b/tests/Feature/Providers/Mistral/AgentFakeTest.php index 5768056dc..fa2a4188b 100644 --- a/tests/Feature/Providers/Mistral/AgentFakeTest.php +++ b/tests/Feature/Providers/Mistral/AgentFakeTest.php @@ -2,7 +2,7 @@ use Tests\Fixtures\Agents\MistralAgent; -test('mistral agent can be faked', function () { +test('mistral agent can be faked', function (): void { MistralAgent::fake(['Test response']); $response = (new MistralAgent)->prompt('Hello'); @@ -10,15 +10,15 @@ expect($response->text)->toBe('Test response'); }); -test('mistral agent fake with closure', function () { - MistralAgent::fake(fn (string $prompt) => "Echo: {$prompt}"); +test('mistral agent fake with closure', function (): void { + MistralAgent::fake(fn (string $prompt): string => "Echo: {$prompt}"); $response = (new MistralAgent)->prompt('Hello world'); expect($response->text)->toBe('Echo: Hello world'); }); -test('mistral agent fake with no predefined responses', function () { +test('mistral agent fake with no predefined responses', function (): void { MistralAgent::fake(); $response = (new MistralAgent)->prompt('Hello'); @@ -26,7 +26,7 @@ expect($response->text)->toBe('Fake response for prompt: Hello'); }); -test('mistral agent fake records prompts', function () { +test('mistral agent fake records prompts', function (): void { MistralAgent::fake(); (new MistralAgent)->prompt('Hello'); @@ -35,11 +35,11 @@ MistralAgent::assertNotPrompted('Goodbye'); }); -test('mistral agent stream can be faked', function () { +test('mistral agent stream can be faked', function (): void { MistralAgent::fake(['Streamed response']); $response = (new MistralAgent)->stream('Hello'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->text)->toBe('Streamed response'); }); diff --git a/tests/Feature/Providers/Mistral/BaseUrlTest.php b/tests/Feature/Providers/Mistral/BaseUrlTest.php index 390152c94..b8a483913 100644 --- a/tests/Feature/Providers/Mistral/BaseUrlTest.php +++ b/tests/Feature/Providers/Mistral/BaseUrlTest.php @@ -5,7 +5,7 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.mistral' => [ ...config('ai.providers.mistral'), 'key' => 'test-key', @@ -14,7 +14,7 @@ $this->customUrl = 'http://localhost:1234/v1'; }); -test('mistral requests use the configured base url', function () { +test('mistral requests use the configured base url', function (): void { configureMistralProvider($this->customUrl); Http::fake([ @@ -29,7 +29,7 @@ mistralAssertRequestSent('POST', "{$this->customUrl}/chat/completions"); }); -test('mistral requests fall back to the default base url', function () { +test('mistral requests fall back to the default base url', function (): void { Http::fake([ '*' => $this->fakeTextResponse('Hello from Mistral'), ]); @@ -53,6 +53,6 @@ function configureMistralProvider(?string $url = null): void function mistralAssertRequestSent(string $method, string $url): void { - Http::assertSent(fn (Request $request) => $request->method() === $method + Http::assertSent(fn (Request $request): bool => $request->method() === $method && $request->url() === $url); } diff --git a/tests/Feature/Providers/Mistral/EmbeddingTest.php b/tests/Feature/Providers/Mistral/EmbeddingTest.php index 2bc00ed11..8ba435027 100644 --- a/tests/Feature/Providers/Mistral/EmbeddingTest.php +++ b/tests/Feature/Providers/Mistral/EmbeddingTest.php @@ -7,19 +7,19 @@ use Laravel\Ai\Exceptions\ProviderOverloadedException; use Laravel\Ai\Exceptions\RateLimitedException; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.mistral' => [ ...config('ai.providers.mistral'), 'key' => 'test-key', ]]); }); -test('embeddings request includes model and input', function () { +test('embeddings request includes model and input', function (): void { Http::fake(['*' => fakeEmbeddingsResponse()]); Embeddings::for(['Hello world'])->generate(provider: 'mistral', model: 'mistral-embed'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'mistral-embed' @@ -28,7 +28,7 @@ }); }); -test('embeddings response is correctly parsed', function () { +test('embeddings response is correctly parsed', function (): void { Http::fake(['*' => fakeEmbeddingsResponse()]); $response = Embeddings::for(['Hello world'])->generate(provider: 'mistral', model: 'mistral-embed'); @@ -39,17 +39,15 @@ ->and($response->meta->provider)->toBe('mistral'); }); -test('embeddings request sends bearer token', function () { +test('embeddings request sends bearer token', function (): void { Http::fake(['*' => fakeEmbeddingsResponse()]); Embeddings::for(['Hello'])->generate(provider: 'mistral', model: 'mistral-embed'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('multiple inputs return multiple embeddings', function () { +test('multiple inputs return multiple embeddings', function (): void { Http::fake(['*' => Http::response([ 'id' => 'embd-123', 'object' => 'list', @@ -66,7 +64,7 @@ expect($response->embeddings)->toHaveCount(2); }); -test('embeddings rate limit response throws rate limited exception', function () { +test('embeddings rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.mistral.ai/*' => Http::response([ 'object' => 'error', @@ -78,7 +76,7 @@ Embeddings::for(['Hello'])->generate(provider: 'mistral', model: 'mistral-embed'); })->throws(RateLimitedException::class); -test('embeddings overloaded response throws provider overloaded exception', function () { +test('embeddings overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.mistral.ai/*' => Http::response([ 'object' => 'error', @@ -90,7 +88,7 @@ Embeddings::for(['Hello'])->generate(provider: 'mistral', model: 'mistral-embed'); })->throws(ProviderOverloadedException::class); -test('embeddings http error response throws request exception', function () { +test('embeddings http error response throws request exception', function (): void { Http::fake([ 'api.mistral.ai/*' => Http::response([ 'object' => 'error', @@ -102,14 +100,14 @@ Embeddings::for(['Hello'])->generate(provider: 'mistral', model: 'mistral-embed'); })->throws(RequestException::class); -test('embeddings request includes provider options in the request body', function () { +test('embeddings request includes provider options in the request body', function (): void { Http::fake(['*' => fakeEmbeddingsResponse()]); Embeddings::for(['Hello']) ->withProviderOptions(['output_dimension' => 256, 'output_dtype' => 'float']) ->generate(provider: 'mistral', model: 'mistral-embed'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['output_dimension'] === 256 diff --git a/tests/Feature/Providers/Mistral/ErrorHandlingTest.php b/tests/Feature/Providers/Mistral/ErrorHandlingTest.php index e91b795e8..bd74306f8 100644 --- a/tests/Feature/Providers/Mistral/ErrorHandlingTest.php +++ b/tests/Feature/Providers/Mistral/ErrorHandlingTest.php @@ -7,14 +7,14 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Tests\Fixtures\Agents\AssistantAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.mistral' => [ ...config('ai.providers.mistral'), 'key' => 'test-key', ]]); }); -test('http error response throws request exception', function () { +test('http error response throws request exception', function (): void { Http::fake([ '*' => Http::response([ 'object' => 'error', @@ -26,7 +26,7 @@ (new AssistantAgent)->prompt('Hi', provider: 'mistral'); })->throws(RequestException::class); -test('rate limit response throws rate limited exception', function () { +test('rate limit response throws rate limited exception', function (): void { Http::fake([ '*' => Http::response([ 'object' => 'error', @@ -38,7 +38,7 @@ (new AssistantAgent)->prompt('Hi', provider: 'mistral'); })->throws(RateLimitedException::class); -test('overloaded response throws provider overloaded exception', function () { +test('overloaded response throws provider overloaded exception', function (): void { Http::fake([ '*' => Http::response([ 'object' => 'error', @@ -50,7 +50,7 @@ (new AssistantAgent)->prompt('Hi', provider: 'mistral'); })->throws(ProviderOverloadedException::class); -test('error in 200 response throws ai exception', function () { +test('error in 200 response throws ai exception', function (): void { Http::fake([ '*' => Http::response([ 'object' => 'error', diff --git a/tests/Feature/Providers/Mistral/MessageMappingTest.php b/tests/Feature/Providers/Mistral/MessageMappingTest.php index 3ebf85853..7af837725 100644 --- a/tests/Feature/Providers/Mistral/MessageMappingTest.php +++ b/tests/Feature/Providers/Mistral/MessageMappingTest.php @@ -11,19 +11,19 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.mistral' => [ ...config('ai.providers.mistral'), 'key' => 'test-key', ]]); }); -test('user message maps to chat format', function () { +test('user message maps to chat format', function (): void { Http::fake(['*' => $this->fakeTextResponse()]); (new AssistantAgent)->prompt('What is Laravel?', provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMsg = collect($body['messages'])->firstWhere('role', 'user'); @@ -31,7 +31,7 @@ }); }); -test('assistant message with tool calls maps correctly', function () { +test('assistant message with tool calls maps correctly', function (): void { Http::fake([ '*' => Http::sequence([ $this->fakeToolCallResponse(), @@ -45,7 +45,7 @@ expect($recorded)->toHaveCount(2); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); $assistantMsg = collect($followUpBody['messages'])->firstWhere('role', 'assistant'); $toolMsg = collect($followUpBody['messages'])->firstWhere('role', 'tool'); @@ -57,7 +57,7 @@ ->and($toolMsg['tool_call_id'])->toBe($assistantMsg['tool_calls'][0]['id']); }); -test('image attachment maps to image url', function () { +test('image attachment maps to image url', function (): void { Http::fake(['*' => $this->fakeTextResponse('I see an image')]); $image = new RemoteImage('https://example.com/image.png'); @@ -68,7 +68,7 @@ provider: 'mistral', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $content = $body['messages'][1]['content'] ?? $body['messages'][0]['content']; @@ -83,7 +83,7 @@ }); }); -test('base64 image attachment maps to data uri', function () { +test('base64 image attachment maps to data uri', function (): void { Http::fake(['*' => $this->fakeTextResponse('I see an image')]); $image = new Base64Image(base64_encode('fake-image-data'), 'image/png'); @@ -94,7 +94,7 @@ provider: 'mistral', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $content = $body['messages'][1]['content'] ?? $body['messages'][0]['content']; @@ -105,11 +105,11 @@ $imageBlock = collect($content)->firstWhere('type', 'image_url'); return $imageBlock !== null - && str_starts_with($imageBlock['image_url']['url'], 'data:image/png;base64,'); + && str_starts_with((string) $imageBlock['image_url']['url'], 'data:image/png;base64,'); }); }); -test('local image attachment without explicit mime type detects mime from file', function () { +test('local image attachment without explicit mime type detects mime from file', function (): void { Http::fake(['*' => $this->fakeTextResponse('I see an image')]); agent('You are helpful.')->prompt( @@ -118,7 +118,7 @@ provider: 'mistral', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $content = $body['messages'][1]['content'] ?? $body['messages'][0]['content']; @@ -129,12 +129,12 @@ $imageBlock = collect($content)->firstWhere('type', 'image_url'); return $imageBlock !== null - && str_starts_with($imageBlock['image_url']['url'], 'data:image/png;base64,') - && ! str_contains($imageBlock['image_url']['url'], 'data:;base64,'); + && str_starts_with((string) $imageBlock['image_url']['url'], 'data:image/png;base64,') + && ! str_contains((string) $imageBlock['image_url']['url'], 'data:;base64,'); }); }); -test('remote document maps to document url', function () { +test('remote document maps to document url', function (): void { Http::fake(['*' => $this->fakeTextResponse('I see a document')]); $document = new RemoteDocument('https://example.com/report.pdf'); @@ -145,7 +145,7 @@ provider: 'mistral', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $content = $body['messages'][1]['content'] ?? $body['messages'][0]['content']; @@ -160,17 +160,17 @@ }); }); -test('system instructions are in messages array', function () { +test('system instructions are in messages array', function (): void { Http::fake(['*' => $this->fakeTextResponse()]); (new AssistantAgent)->prompt('Hi', provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $systemMsg = collect($body['messages'])->firstWhere('role', 'system'); return $systemMsg !== null - && str_contains($systemMsg['content'], 'helpful assistant'); + && str_contains((string) $systemMsg['content'], 'helpful assistant'); }); }); diff --git a/tests/Feature/Providers/Mistral/ProviderOptionsTest.php b/tests/Feature/Providers/Mistral/ProviderOptionsTest.php index a4c221be3..92222ae07 100644 --- a/tests/Feature/Providers/Mistral/ProviderOptionsTest.php +++ b/tests/Feature/Providers/Mistral/ProviderOptionsTest.php @@ -7,19 +7,19 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.mistral' => [ ...config('ai.providers.mistral'), 'key' => 'test-key', ]]); }); -test('provider options are included in mistral request body', function () { +test('provider options are included in mistral request body', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); (new ProviderOptionsAgent)->prompt('Hello', provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'frequency_penalty') === 0.5 @@ -27,12 +27,12 @@ }); }); -test('request body does not contain provider options when agent does not implement interface', function () { +test('request body does not contain provider options when agent does not implement interface', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); agent()->prompt('Hello', provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('frequency_penalty', $body) @@ -40,7 +40,7 @@ }); }); -test('provider options are persisted in tool call follow up requests', function () { +test('provider options are persisted in tool call follow up requests', function (): void { Http::fake([ '*' => Http::sequence([ $this->fakeToolCallResponse(), @@ -50,11 +50,11 @@ (new ProviderOptionsWithToolsAgent)->prompt('Give me a number', provider: 'mistral'); - $requests = Http::recorded(fn (Request $r) => true); + $requests = Http::recorded(fn (Request $r): true => true); expect(count($requests))->toBeGreaterThanOrEqual(2); - $followUpBody = json_decode($requests[1][0]->body(), true); + $followUpBody = json_decode((string) $requests[1][0]->body(), true); expect(data_get($followUpBody, 'frequency_penalty'))->toBe(0.5); }); diff --git a/tests/Feature/Providers/Mistral/RequestMappingTest.php b/tests/Feature/Providers/Mistral/RequestMappingTest.php index fde023f59..0cf6fa3c4 100644 --- a/tests/Feature/Providers/Mistral/RequestMappingTest.php +++ b/tests/Feature/Providers/Mistral/RequestMappingTest.php @@ -11,47 +11,47 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.mistral' => [ ...config('ai.providers.mistral'), 'key' => 'test-key', ]]); }); -test('request includes model and messages', function () { +test('request includes model and messages', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); agent()->prompt('Hi there', provider: 'mistral', model: 'mistral-large-latest'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'mistral-large-latest' && count($body['messages']) >= 1 - && collect($body['messages'])->contains(fn ($m) => $m['role'] === 'user' && $m['content'] === 'Hi there'); + && collect($body['messages'])->contains(fn ($m): bool => $m['role'] === 'user' && $m['content'] === 'Hi there'); }); }); -test('system instructions are sent as system message', function () { +test('system instructions are sent as system message', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); (new AssistantAgent)->prompt('Hello', provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $systemMsg = collect($body['messages'])->firstWhere('role', 'system'); return $systemMsg !== null - && str_contains($systemMsg['content'], 'helpful assistant'); + && str_contains((string) $systemMsg['content'], 'helpful assistant'); }); }); -test('temperature and max tokens are included when set via attributes', function () { +test('temperature and max tokens are included when set via attributes', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); (new AttributeAgent)->prompt('Hello', provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'temperature') === 0.7 @@ -59,12 +59,12 @@ }); }); -test('temperature and max tokens are excluded when not set', function () { +test('temperature and max tokens are excluded when not set', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); agent()->prompt('Hello', provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('temperature', $body) @@ -72,26 +72,26 @@ }); }); -test('tools include tool choice auto', function () { +test('tools include tool choice auto', function (): void { Http::fake(['*' => $this->fakeTextResponse('42')]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a number', provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['tool_choice'] === 'auto' && is_array($body['tools']) - && count($body['tools']) > 0; + && $body['tools'] !== []; }); }); -test('request without tools excludes tool fields', function () { +test('request without tools excludes tool fields', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); agent()->prompt('Hello', provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('tools', $body) @@ -99,49 +99,47 @@ }); }); -test('required tool choice forces the model to call a tool', function () { +test('required tool choice forces the model to call a tool', function (): void { Http::fake(['*' => $this->fakeTextResponse('42')]); (new ToolChoiceAgent('required'))->prompt('Give me a number', provider: 'mistral'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'required'); }); -test('required tool choice can be set via attribute', function () { +test('required tool choice can be set via attribute', function (): void { Http::fake(['*' => $this->fakeTextResponse('42')]); (new AttributeToolChoiceAgent)->prompt('Give me a number', provider: 'mistral'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'required'); }); -test('named tool choice forces a specific function', function () { +test('named tool choice forces a specific function', function (): void { Http::fake(['*' => $this->fakeTextResponse('42')]); (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Give me a number', provider: 'mistral'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['tool_choice'] === [ - 'type' => 'function', - 'function' => ['name' => 'custom_named_tool'], - ]; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === [ + 'type' => 'function', + 'function' => ['name' => 'custom_named_tool'], + ]); }); -test('none tool choice prevents tool calls', function () { +test('none tool choice prevents tool calls', function (): void { Http::fake(['*' => $this->fakeTextResponse('Sure')]); (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'mistral'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'none'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'none'); }); -test('structured output includes json schema response format', function () { +test('structured output includes json schema response format', function (): void { Http::fake(['*' => $this->fakeStructuredResponse('{"symbol": "Au"}')]); (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $format = data_get($body, 'response_format'); @@ -152,7 +150,7 @@ }); }); -test('streaming request includes stream options', function () { +test('streaming request includes stream options', function (): void { Http::fake(['*' => Http::response("data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hi\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1}}\n\ndata: [DONE]\n\n")]); $stream = agent()->stream('Hello', provider: 'mistral'); @@ -161,7 +159,7 @@ // } - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['stream'] === true @@ -169,17 +167,15 @@ }); }); -test('request sends bearer token authorization', function () { +test('request sends bearer token authorization', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); agent()->prompt('Hello', provider: 'mistral'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('response text is correctly parsed', function () { +test('response text is correctly parsed', function (): void { Http::fake(['*' => $this->fakeTextResponse('Laravel is great')]); $response = agent()->prompt('Tell me about Laravel', provider: 'mistral'); @@ -188,7 +184,7 @@ ->and($response->meta->provider)->toBe('mistral'); }); -test('response usage is correctly parsed', function () { +test('response usage is correctly parsed', function (): void { Http::fake(['*' => Http::response([ 'id' => 'chatcmpl-123', 'object' => 'chat.completion', @@ -210,7 +206,7 @@ ->and($response->usage->completionTokens)->toBe(5); }); -test('structured response is correctly parsed', function () { +test('structured response is correctly parsed', function (): void { Http::fake(['*' => $this->fakeStructuredResponse('{"symbol": "Au"}')]); $response = (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'mistral'); diff --git a/tests/Feature/Providers/Mistral/StreamingTest.php b/tests/Feature/Providers/Mistral/StreamingTest.php index 638aec200..28bec77b3 100644 --- a/tests/Feature/Providers/Mistral/StreamingTest.php +++ b/tests/Feature/Providers/Mistral/StreamingTest.php @@ -12,14 +12,14 @@ use Laravel\Ai\Streaming\Events\ToolResult as ToolResultEvent; use Tests\Fixtures\Agents\ProviderOptionsWithToolsAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.mistral' => [ ...config('ai.providers.mistral'), 'key' => 'test-key', ]]); }); -test('streaming emits text events', function () { +test('streaming emits text events', function (): void { Http::fake([ '*' => Http::response( body: $this->ssePayload([ @@ -42,7 +42,7 @@ ->and($events[5])->toBeInstanceOf(StreamEnd::class); }); -test('streaming handles tool calls', function () { +test('streaming handles tool calls', function (): void { Http::fake([ '*' => Http::sequence([ Http::response( @@ -67,9 +67,9 @@ $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); - $toolCallEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolCallEvent)); - $toolResultEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolResultEvent)); - $streamEndEvents = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd)); + $toolCallEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolCallEvent)); + $toolResultEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolResultEvent)); + $streamEndEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd)); expect($toolCallEvents)->not->toBeEmpty() ->and($toolCallEvents[0]->toolCall->name)->toBe('FixedNumberGenerator') @@ -80,7 +80,7 @@ ->and($streamEndEvents[0]->usage->completionTokens)->toBe(15); }); -test('streaming captures usage', function () { +test('streaming captures usage', function (): void { Http::fake([ '*' => Http::response( body: $this->ssePayload([ @@ -94,13 +94,13 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->usage->promptTokens)->toBe(10) ->and($streamEnd->usage->completionTokens)->toBe(5); }); -test('streaming error event stops stream', function () { +test('streaming error event stops stream', function (): void { Http::fake([ '*' => Http::response( body: $this->ssePayload([ @@ -119,7 +119,7 @@ ->and($events[0]->message)->toBe('Internal server error'); }); -test('streaming finish reason maps correctly', function (string $apiReason, $expected) { +test('streaming finish reason maps correctly', function (string $apiReason, $expected): void { Http::fake([ '*' => Http::response( body: $this->ssePayload([ @@ -133,7 +133,7 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->reason)->toBe($expected->value); })->with([ diff --git a/tests/Feature/Providers/Mistral/ToolCallLoopTest.php b/tests/Feature/Providers/Mistral/ToolCallLoopTest.php index cd1be600f..7cc62b759 100644 --- a/tests/Feature/Providers/Mistral/ToolCallLoopTest.php +++ b/tests/Feature/Providers/Mistral/ToolCallLoopTest.php @@ -2,17 +2,18 @@ use Illuminate\Support\Facades\Http; use Laravel\Ai\Exceptions\NoSuchToolException; +use Laravel\Ai\Responses\AgentResponse; use Tests\Fixtures\Agents\MultiStepToolAgent; use Tests\Fixtures\Agents\ToolUsingAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.mistral' => [ ...config('ai.providers.mistral'), 'key' => 'test-key', ]]); }); -test('tool calls trigger follow up request', function () { +test('tool calls trigger follow up request', function (): void { Http::fake([ '*' => Http::sequence([ $this->fakeToolCallResponse('FixedNumberGenerator', 'call_'.uniqid()), @@ -29,7 +30,7 @@ expect($recorded)->toHaveCount(2); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); $hasAssistantWithToolCalls = false; $hasToolResult = false; @@ -48,7 +49,7 @@ ->and($hasToolResult)->toBeTrue(); }); -test('max steps limits tool call depth', function () { +test('max steps limits tool call depth', function (): void { Http::fake([ '*' => Http::sequence([ $this->fakeToolCallResponse('FixedNumberGenerator', 'call_'.uniqid()), @@ -68,7 +69,7 @@ expect(count($recorded))->toBeLessThanOrEqual(3); }); -test('multi step tool loop returns accumulated response shape', function () { +test('multi step tool loop returns accumulated response shape', function (): void { Http::fake([ '*' => Http::sequence([ $this->fakeToolCallResponse('FixedNumberGenerator', 'call_'.uniqid()), @@ -91,20 +92,20 @@ ->and($response->usage->completionTokens)->toBe(15); }); -test('unregistered tool call throws', function () { +test('unregistered tool call throws', function (): void { Http::fake([ '*' => Http::sequence([ $this->fakeToolCallResponse('NonExistentTool', 'call_'.uniqid()), ]), ]); - expect(fn () => (new MultiStepToolAgent)->prompt( + expect(fn (): AgentResponse => (new MultiStepToolAgent)->prompt( 'Generate numbers', provider: 'mistral', ))->toThrow(NoSuchToolException::class); }); -test('follow up request includes original messages', function () { +test('follow up request includes original messages', function (): void { Http::fake([ '*' => Http::sequence([ $this->fakeToolCallResponse('FixedNumberGenerator', 'call_'.uniqid()), @@ -119,7 +120,7 @@ $recorded = Http::recorded(); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); $userMsg = collect($followUpBody['messages'])->firstWhere('role', 'user'); diff --git a/tests/Feature/Providers/Mistral/ToolMappingTest.php b/tests/Feature/Providers/Mistral/ToolMappingTest.php index b6be088f5..7a2c811c0 100644 --- a/tests/Feature/Providers/Mistral/ToolMappingTest.php +++ b/tests/Feature/Providers/Mistral/ToolMappingTest.php @@ -9,19 +9,19 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.mistral' => [ ...config('ai.providers.mistral'), 'key' => 'test-key', ]]); }); -test('tool with parameters includes correct schema', function () { +test('tool with parameters includes correct schema', function (): void { Http::fake(['*' => $this->fakeTextResponse('42')]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a random number', provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; @@ -35,12 +35,12 @@ }); }); -test('tool with empty schema includes parameters', function () { +test('tool with empty schema includes parameters', function (): void { Http::fake(['*' => $this->fakeTextResponse('72019')]); agent(tools: [new FixedNumberGenerator])->prompt('Give me a random number', provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; @@ -53,7 +53,7 @@ }); }); -test('provider tools throw runtime exception', function () { +test('provider tools throw runtime exception', function (): void { Http::fake(['*' => $this->fakeTextResponse()]); agent( @@ -61,12 +61,12 @@ )->prompt('Search for something', provider: 'mistral'); })->throws(RuntimeException::class, 'Mistral does not support'); -test('tool with a name() method emits the declared name', function () { +test('tool with a name() method emits the declared name', function (): void { Http::fake(['*' => $this->fakeTextResponse('ok')]); agent(tools: [new NamedTool('my_custom_tool')])->prompt('Hi', provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $names = collect(data_get($body, 'tools'))->pluck('function.name')->all(); @@ -74,12 +74,12 @@ }); }); -test('tool parameters are not wrapped in schema definition', function () { +test('tool parameters are not wrapped in schema definition', function (): void { Http::fake(['*' => $this->fakeTextResponse('done')]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a random number', provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; diff --git a/tests/Feature/Providers/Mistral/TranscriptionTest.php b/tests/Feature/Providers/Mistral/TranscriptionTest.php index 8216bee30..62ec4ed2e 100644 --- a/tests/Feature/Providers/Mistral/TranscriptionTest.php +++ b/tests/Feature/Providers/Mistral/TranscriptionTest.php @@ -5,26 +5,24 @@ use Illuminate\Support\Facades\Http; use Laravel\Ai\Transcription; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.mistral' => [ ...config('ai.providers.mistral'), 'key' => 'test-key', ]]); }); -test('transcription request posts to correct endpoint', function () { +test('transcription request posts to correct endpoint', function (): void { Http::fake(['*' => fakeTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->generate(provider: 'mistral'); - Http::assertSent(function (Request $request) { - return $request->url() === 'https://api.mistral.ai/v1/audio/transcriptions' - && str_contains($request->header('Content-Type')[0] ?? '', 'multipart/form-data'); - }); + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.mistral.ai/v1/audio/transcriptions' + && str_contains($request->header('Content-Type')[0] ?? '', 'multipart/form-data')); }); -test('transcription response text is correctly parsed', function () { +test('transcription response text is correctly parsed', function (): void { Http::fake(['*' => fakeTranscriptionResponse('Hello, world!')]); $response = Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') @@ -34,51 +32,45 @@ ->and($response->meta->provider)->toBe('mistral'); }); -test('transcription includes model in request', function () { +test('transcription includes model in request', function (): void { Http::fake(['*' => fakeTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->generate(provider: 'mistral'); - Http::assertSent(function (Request $request) { - return str_contains($request->body(), 'voxtral-mini-latest'); - }); + Http::assertSent(fn (Request $request): bool => str_contains($request->body(), 'voxtral-mini-latest')); }); -test('transcription sends language when provided', function () { +test('transcription sends language when provided', function (): void { Http::fake(['*' => fakeTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->language('en') ->generate(provider: 'mistral'); - Http::assertSent(function (Request $request) { - return str_contains($request->body(), 'language') - && str_contains($request->body(), 'en'); - }); + Http::assertSent(fn (Request $request): bool => str_contains($request->body(), 'language') + && str_contains($request->body(), 'en')); }); -test('transcription sends context bias from provider options', function () { +test('transcription sends context bias from provider options', function (): void { Http::fake(['*' => fakeTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->withProviderOptions(['context_bias' => 'Laravel,Forge,Vapor']) ->generate(provider: 'mistral'); - Http::assertSent(function (Request $request) { - return str_contains($request->body(), 'context_bias') - && str_contains($request->body(), 'Laravel,Forge,Vapor'); - }); + Http::assertSent(fn (Request $request): bool => str_contains($request->body(), 'context_bias') + && str_contains($request->body(), 'Laravel,Forge,Vapor')); }); -test('transcription sends context bias array as repeated parts', function () { +test('transcription sends context bias array as repeated parts', function (): void { Http::fake(['*' => fakeTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->withProviderOptions(['context_bias' => ['Laravel', 'Forge', 'Vapor']]) ->generate(provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = $request->body(); return substr_count($body, 'name="context_bias"') === 3 @@ -88,18 +80,16 @@ }); }); -test('transcription sends bearer token', function () { +test('transcription sends bearer token', function (): void { Http::fake(['*' => fakeTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->generate(provider: 'mistral'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('transcription usage is correctly parsed', function () { +test('transcription usage is correctly parsed', function (): void { Http::fake(['*' => Http::response([ 'text' => 'Hello', 'usage' => [ @@ -115,7 +105,7 @@ ->and($response->usage->completionTokens)->toBe(50); }); -test('transcription omits language and sends diarize flag when diarizing', function () { +test('transcription omits language and sends diarize flag when diarizing', function (): void { Http::fake(['*' => fakeTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') @@ -123,7 +113,7 @@ ->diarize() ->generate(provider: 'mistral'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = $request->body(); return str_contains($body, 'diarize') @@ -134,7 +124,7 @@ }); }); -test('transcription response segments are parsed when diarizing', function () { +test('transcription response segments are parsed when diarizing', function (): void { Http::fake(['*' => Http::response([ 'text' => 'Hello world', 'segments' => [ @@ -156,7 +146,7 @@ ->and($response->segments[1]->speaker)->toBe('speaker_1'); }); -test('transcription throws when the API returns an error', function () { +test('transcription throws when the API returns an error', function (): void { Http::fake(['*' => Http::response(['message' => 'Unauthorized'], 401)]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') diff --git a/tests/Feature/Providers/Ollama/AgentFakeTest.php b/tests/Feature/Providers/Ollama/AgentFakeTest.php index 55268e80f..a48dee59f 100644 --- a/tests/Feature/Providers/Ollama/AgentFakeTest.php +++ b/tests/Feature/Providers/Ollama/AgentFakeTest.php @@ -2,7 +2,7 @@ use Tests\Fixtures\Agents\OllamaAgent; -test('ollama agent can be faked', function () { +test('ollama agent can be faked', function (): void { OllamaAgent::fake(['Test response']); $response = (new OllamaAgent)->prompt('Hello'); @@ -10,15 +10,15 @@ expect($response->text)->toBe('Test response'); }); -test('ollama agent fake with closure', function () { - OllamaAgent::fake(fn (string $prompt) => "Echo: {$prompt}"); +test('ollama agent fake with closure', function (): void { + OllamaAgent::fake(fn (string $prompt): string => "Echo: {$prompt}"); $response = (new OllamaAgent)->prompt('Hello world'); expect($response->text)->toBe('Echo: Hello world'); }); -test('ollama agent fake with no predefined responses', function () { +test('ollama agent fake with no predefined responses', function (): void { OllamaAgent::fake(); $response = (new OllamaAgent)->prompt('Hello'); @@ -26,7 +26,7 @@ expect($response->text)->toBe('Fake response for prompt: Hello'); }); -test('ollama agent fake records prompts', function () { +test('ollama agent fake records prompts', function (): void { OllamaAgent::fake(); (new OllamaAgent)->prompt('Hello'); @@ -35,11 +35,11 @@ OllamaAgent::assertNotPrompted('Goodbye'); }); -test('ollama agent stream can be faked', function () { +test('ollama agent stream can be faked', function (): void { OllamaAgent::fake(['Streamed response']); $response = (new OllamaAgent)->stream('Hello'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->text)->toBe('Streamed response'); }); diff --git a/tests/Feature/Providers/Ollama/BaseUrlTest.php b/tests/Feature/Providers/Ollama/BaseUrlTest.php index 5df7b2b85..cdae4d3db 100644 --- a/tests/Feature/Providers/Ollama/BaseUrlTest.php +++ b/tests/Feature/Providers/Ollama/BaseUrlTest.php @@ -6,11 +6,11 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { $this->customUrl = 'http://localhost:1234'; }); -test('ollama text requests use the configured base url', function () { +test('ollama text requests use the configured base url', function (): void { configureOllamaProvider($this->customUrl); Http::fake([ @@ -32,7 +32,7 @@ ollamaAssertRequestSent('POST', "{$this->customUrl}/api/chat"); }); -test('ollama requests fall back to the default base url', function () { +test('ollama requests fall back to the default base url', function (): void { configureOllamaProvider(); Http::fake([ @@ -54,7 +54,7 @@ ollamaAssertRequestSent('POST', 'http://localhost:11434/api/chat'); }); -test('ollama embeddings use the configured base url', function () { +test('ollama embeddings use the configured base url', function (): void { configureOllamaProvider($this->customUrl); Http::fake([ @@ -88,6 +88,6 @@ function configureOllamaProvider(?string $url = null): void function ollamaAssertRequestSent(string $method, string $url): void { - Http::assertSent(fn (Request $request) => $request->method() === $method + Http::assertSent(fn (Request $request): bool => $request->method() === $method && $request->url() === $url); } diff --git a/tests/Feature/Providers/Ollama/EmbeddingTest.php b/tests/Feature/Providers/Ollama/EmbeddingTest.php index f671bd2d2..286e2a128 100644 --- a/tests/Feature/Providers/Ollama/EmbeddingTest.php +++ b/tests/Feature/Providers/Ollama/EmbeddingTest.php @@ -7,7 +7,7 @@ use Laravel\Ai\Exceptions\ProviderOverloadedException; use Laravel\Ai\Exceptions\RateLimitedException; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.ollama' => [ ...config('ai.providers.ollama'), 'key' => '', @@ -15,12 +15,12 @@ ]]); }); -test('embeddings request includes model and input', function () { +test('embeddings request includes model and input', function (): void { Http::fake(['*' => fakeOllamaEmbeddingsResponse()]); Embeddings::for(['Hello world'])->generate(provider: 'ollama', model: 'nomic-embed-text'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'nomic-embed-text' @@ -29,7 +29,7 @@ }); }); -test('embeddings response is correctly parsed', function () { +test('embeddings response is correctly parsed', function (): void { Http::fake(['*' => fakeOllamaEmbeddingsResponse()]); $response = Embeddings::for(['Hello world'])->generate(provider: 'ollama', model: 'nomic-embed-text'); @@ -40,7 +40,7 @@ ->and($response->meta->provider)->toBe('ollama'); }); -test('multiple inputs return multiple embeddings', function () { +test('multiple inputs return multiple embeddings', function (): void { Http::fake(['*' => Http::response([ 'model' => 'nomic-embed-text', 'embeddings' => [ @@ -55,7 +55,7 @@ expect($response->embeddings)->toHaveCount(2); }); -test('embeddings request sends bearer token when key is set', function () { +test('embeddings request sends bearer token when key is set', function (): void { config(['ai.providers.ollama' => [ ...config('ai.providers.ollama'), 'key' => 'test-key', @@ -65,22 +65,18 @@ Embeddings::for(['Hello'])->generate(provider: 'ollama', model: 'nomic-embed-text'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('embeddings request sends no authorization header when key is empty', function () { +test('embeddings request sends no authorization header when key is empty', function (): void { Http::fake(['*' => fakeOllamaEmbeddingsResponse()]); Embeddings::for(['Hello'])->generate(provider: 'ollama', model: 'nomic-embed-text'); - Http::assertSent(function (Request $request) { - return ! $request->hasHeader('Authorization'); - }); + Http::assertSent(fn (Request $request): bool => ! $request->hasHeader('Authorization')); }); -test('embeddings rate limit response throws rate limited exception', function () { +test('embeddings rate limit response throws rate limited exception', function (): void { Http::fake([ 'localhost:11434/*' => Http::response([ 'error' => 'rate limit exceeded', @@ -90,7 +86,7 @@ Embeddings::for(['Hello'])->generate(provider: 'ollama', model: 'nomic-embed-text'); })->throws(RateLimitedException::class); -test('embeddings overloaded response throws provider overloaded exception', function () { +test('embeddings overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'localhost:11434/*' => Http::response([ 'error' => 'server overloaded', @@ -100,7 +96,7 @@ Embeddings::for(['Hello'])->generate(provider: 'ollama', model: 'nomic-embed-text'); })->throws(ProviderOverloadedException::class); -test('embeddings http error response throws request exception', function () { +test('embeddings http error response throws request exception', function (): void { Http::fake([ 'localhost:11434/*' => Http::response([ 'error' => 'model not found', @@ -110,14 +106,14 @@ Embeddings::for(['Hello'])->generate(provider: 'ollama', model: 'nomic-embed-text'); })->throws(RequestException::class); -test('embeddings request includes provider options in the request body', function () { +test('embeddings request includes provider options in the request body', function (): void { Http::fake(['*' => fakeOllamaEmbeddingsResponse()]); Embeddings::for(['Hello']) ->withProviderOptions(['truncate' => false, 'keep_alive' => '5m']) ->generate(provider: 'ollama', model: 'nomic-embed-text'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['truncate'] === false diff --git a/tests/Feature/Providers/Ollama/ErrorHandlingTest.php b/tests/Feature/Providers/Ollama/ErrorHandlingTest.php index f941f3a5b..155a501a8 100644 --- a/tests/Feature/Providers/Ollama/ErrorHandlingTest.php +++ b/tests/Feature/Providers/Ollama/ErrorHandlingTest.php @@ -7,7 +7,7 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Tests\Fixtures\Agents\AssistantAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.ollama' => [ ...config('ai.providers.ollama'), 'key' => '', @@ -15,7 +15,7 @@ ]]); }); -test('http error response throws request exception', function () { +test('http error response throws request exception', function (): void { Http::fake([ 'localhost:11434/*' => Http::response([ 'error' => 'model not found', @@ -28,7 +28,7 @@ ); })->throws(RequestException::class); -test('rate limit response throws rate limited exception', function () { +test('rate limit response throws rate limited exception', function (): void { Http::fake([ 'localhost:11434/*' => Http::response([ 'error' => 'rate limit exceeded', @@ -41,7 +41,7 @@ ); })->throws(RateLimitedException::class); -test('overloaded response throws provider overloaded exception', function () { +test('overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'localhost:11434/*' => Http::response([ 'error' => 'server overloaded', @@ -54,7 +54,7 @@ ); })->throws(ProviderOverloadedException::class); -test('error in 200 response throws ai exception', function () { +test('error in 200 response throws ai exception', function (): void { Http::fake([ 'localhost:11434/*' => Http::response([ 'error' => 'model "unknown-model" not found', diff --git a/tests/Feature/Providers/Ollama/MessageMappingTest.php b/tests/Feature/Providers/Ollama/MessageMappingTest.php index 8d142f953..96adcf565 100644 --- a/tests/Feature/Providers/Ollama/MessageMappingTest.php +++ b/tests/Feature/Providers/Ollama/MessageMappingTest.php @@ -9,14 +9,14 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.ollama' => [ ...config('ai.providers.ollama'), 'key' => '', ]]); }); -test('user message maps to ollama format', function () { +test('user message maps to ollama format', function (): void { Http::fake([ '*' => $this->fakeTextResponse(), ]); @@ -26,7 +26,7 @@ provider: 'ollama', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $messages = $body['messages']; $userMessage = collect($messages)->firstWhere('role', 'user'); @@ -36,7 +36,7 @@ }); }); -test('tool result follow up uses tool_name not tool_call_id', function () { +test('tool result follow up uses tool_name not tool_call_id', function (): void { Http::fake([ '*' => Http::sequence([ $this->fakeToolCallResponse(), @@ -53,10 +53,10 @@ expect($recorded)->toHaveCount(2); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); $followUpMessages = $followUpBody['messages']; - $toolMsg = collect($followUpMessages)->first(fn ($m) => $m['role'] === 'tool'); + $toolMsg = collect($followUpMessages)->first(fn ($m): bool => $m['role'] === 'tool'); expect($toolMsg)->not->toBeNull() ->and($toolMsg)->toHaveKey('tool_name') @@ -64,7 +64,7 @@ ->and($toolMsg['tool_name'])->toBe('FixedNumberGenerator'); }); -test('assistant tool call message uses function format without type', function () { +test('assistant tool call message uses function format without type', function (): void { Http::fake([ '*' => Http::sequence([ $this->fakeToolCallResponse(), @@ -78,11 +78,11 @@ ); $recorded = Http::recorded(); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); $followUpMessages = $followUpBody['messages']; $assistantMsg = collect($followUpMessages)->first( - fn ($m) => $m['role'] === 'assistant' && isset($m['tool_calls']) + fn ($m): bool => $m['role'] === 'assistant' && isset($m['tool_calls']) ); expect($assistantMsg)->not->toBeNull(); @@ -94,7 +94,7 @@ ->and($toolCall['function']['name'])->toBe('FixedNumberGenerator'); }); -test('image attachment maps to images array with base64', function () { +test('image attachment maps to images array with base64', function (): void { Http::fake([ '*' => $this->fakeTextResponse('I see an image'), ]); @@ -107,7 +107,7 @@ provider: 'ollama', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['messages'])->firstWhere('role', 'user'); @@ -117,7 +117,7 @@ }); }); -test('document attachments throw exception', function () { +test('document attachments throw exception', function (): void { Http::fake([ '*' => $this->fakeTextResponse(), ]); diff --git a/tests/Feature/Providers/Ollama/OllamaHelpers.php b/tests/Feature/Providers/Ollama/OllamaHelpers.php index 531cf171e..3af2a940f 100644 --- a/tests/Feature/Providers/Ollama/OllamaHelpers.php +++ b/tests/Feature/Providers/Ollama/OllamaHelpers.php @@ -80,7 +80,7 @@ protected function collectStreamEvents(?object $agent = null): array protected function ndjsonPayload(array $chunks): string { - return implode("\n", array_map('json_encode', $chunks))."\n"; + return implode("\n", array_map(json_encode(...), $chunks))."\n"; } protected function chatChunk(string $content, bool $done = false, ?string $doneReason = null, ?array $usage = null): array diff --git a/tests/Feature/Providers/Ollama/ProviderOptionsTest.php b/tests/Feature/Providers/Ollama/ProviderOptionsTest.php index ccf74872d..587f8c8f5 100644 --- a/tests/Feature/Providers/Ollama/ProviderOptionsTest.php +++ b/tests/Feature/Providers/Ollama/ProviderOptionsTest.php @@ -9,42 +9,42 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.ollama' => [ ...config('ai.providers.ollama'), 'key' => '', ]]); }); -test('provider options are included in ollama options object', function () { +test('provider options are included in ollama options object', function (): void { Http::fake([ '*' => $this->fakeTextResponse('Hello'), ]); (new ProviderOptionsAgent)->prompt('Hello', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return array_key_exists('options', $body); }); }); -test('request body does not contain provider options when agent does not implement interface', function () { +test('request body does not contain provider options when agent does not implement interface', function (): void { Http::fake([ '*' => $this->fakeTextResponse('Hello'), ]); agent()->prompt('Hello', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('options', $body); }); }); -test('provider options are persisted in tool call follow up requests', function () { +test('provider options are persisted in tool call follow up requests', function (): void { Http::fake([ '*' => Http::sequence([ $this->fakeToolCallResponse(), @@ -54,21 +54,21 @@ (new ProviderOptionsWithToolsAgent)->prompt('Give me a number', provider: 'ollama'); - $requests = Http::recorded(fn (Request $r) => true); + $requests = Http::recorded(fn (Request $r): true => true); expect(count($requests))->toBeGreaterThanOrEqual(2); - $followUpBody = json_decode($requests[1][0]->body(), true); + $followUpBody = json_decode((string) $requests[1][0]->body(), true); expect(array_key_exists('options', $followUpBody))->toBeTrue(); }); -test('top-level provider options are placed at the body root, not inside options', function () { +test('top-level provider options are placed at the body root, not inside options', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); (new OllamaTopLevelOptionsAgent)->prompt('Hello', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ($body['format'] ?? null) === 'json' @@ -80,12 +80,12 @@ }); }); -test('model parameters from provider options remain inside the options object', function () { +test('model parameters from provider options remain inside the options object', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); (new OllamaTopLevelOptionsAgent)->prompt('Hello', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ($body['options']['num_ctx'] ?? null) === 8192 @@ -93,12 +93,12 @@ }); }); -test('structured output schema is not overwritten by a provider options format', function () { +test('structured output schema is not overwritten by a provider options format', function (): void { Http::fake(['*' => $this->fakeStructuredResponse('{"symbol": "Au"}')]); (new OllamaStructuredProviderOptionsAgent)->prompt('What is the symbol for Gold?', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return array_key_exists('format', $body) diff --git a/tests/Feature/Providers/Ollama/RequestMappingTest.php b/tests/Feature/Providers/Ollama/RequestMappingTest.php index f0c2a9642..f7a74ce5c 100644 --- a/tests/Feature/Providers/Ollama/RequestMappingTest.php +++ b/tests/Feature/Providers/Ollama/RequestMappingTest.php @@ -9,47 +9,47 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.ollama' => [ ...config('ai.providers.ollama'), 'key' => '', ]]); }); -test('request includes model and messages', function () { +test('request includes model and messages', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); agent()->prompt('Hi there', provider: 'ollama', model: 'llama3.1:8b'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'llama3.1:8b' && count($body['messages']) >= 1 - && collect($body['messages'])->contains(fn ($m) => $m['role'] === 'user' && $m['content'] === 'Hi there'); + && collect($body['messages'])->contains(fn ($m): bool => $m['role'] === 'user' && $m['content'] === 'Hi there'); }); }); -test('system instructions are sent as system message', function () { +test('system instructions are sent as system message', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); (new AssistantAgent)->prompt('Hello', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $systemMsg = collect($body['messages'])->firstWhere('role', 'system'); return $systemMsg !== null - && str_contains($systemMsg['content'], 'helpful assistant'); + && str_contains((string) $systemMsg['content'], 'helpful assistant'); }); }); -test('temperature and max tokens are sent in options object', function () { +test('temperature and max tokens are sent in options object', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); (new AttributeAgent)->prompt('Hello', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'options.temperature') === 0.7 @@ -57,12 +57,12 @@ }); }); -test('temperature and max tokens are excluded when not set', function () { +test('temperature and max tokens are excluded when not set', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); agent()->prompt('Hello', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('options', $body) @@ -71,37 +71,37 @@ }); }); -test('tools are included in the request', function () { +test('tools are included in the request', function (): void { Http::fake(['*' => $this->fakeTextResponse('42')]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a number', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return is_array($body['tools']) - && count($body['tools']) > 0; + && $body['tools'] !== []; }); }); -test('request without tools excludes tools field', function () { +test('request without tools excludes tools field', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); agent()->prompt('Hello', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('tools', $body); }); }); -test('structured output includes format field', function () { +test('structured output includes format field', function (): void { Http::fake(['*' => $this->fakeStructuredResponse('{"symbol": "Au"}')]); (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return array_key_exists('format', $body) @@ -109,46 +109,46 @@ }); }); -test('structured output appends json schema instruction to system message', function () { +test('structured output appends json schema instruction to system message', function (): void { Http::fake(['*' => $this->fakeStructuredResponse('{"symbol": "Au"}')]); (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $systemMessage = collect($body['messages'])->firstWhere('role', 'system'); return $systemMessage !== null - && str_contains($systemMessage['content'], 'You MUST respond EXCLUSIVELY with a JSON object that strictly adheres to the following schema') - && str_contains($systemMessage['content'], '"symbol"'); + && str_contains((string) $systemMessage['content'], 'You MUST respond EXCLUSIVELY with a JSON object that strictly adheres to the following schema') + && str_contains((string) $systemMessage['content'], '"symbol"'); }); }); -test('request without schema excludes format field', function () { +test('request without schema excludes format field', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); agent()->prompt('Hello', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('format', $body); }); }); -test('non-streaming request sets stream to false', function () { +test('non-streaming request sets stream to false', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); agent()->prompt('Hello', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['stream'] === false; }); }); -test('response text is correctly parsed', function () { +test('response text is correctly parsed', function (): void { Http::fake(['*' => $this->fakeTextResponse('Laravel is great')]); $response = agent()->prompt('Tell me about Laravel', provider: 'ollama'); @@ -157,7 +157,7 @@ ->and($response->meta->provider)->toBe('ollama'); }); -test('response usage is correctly parsed', function () { +test('response usage is correctly parsed', function (): void { Http::fake(['*' => Http::response([ 'model' => 'llama3.1:8b', 'message' => ['role' => 'assistant', 'content' => 'Hello'], @@ -173,7 +173,7 @@ ->and($response->usage->completionTokens)->toBe(5); }); -test('structured response is correctly parsed', function () { +test('structured response is correctly parsed', function (): void { Http::fake(['*' => $this->fakeStructuredResponse('{"symbol": "Au"}')]); $response = (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'ollama'); @@ -181,12 +181,10 @@ expect($response->structured['symbol'])->toBe('Au'); }); -test('request is sent to the chat endpoint', function () { +test('request is sent to the chat endpoint', function (): void { Http::fake(['*' => $this->fakeTextResponse('Hello')]); agent()->prompt('Hello', provider: 'ollama'); - Http::assertSent(function (Request $request) { - return str_ends_with($request->url(), '/api/chat'); - }); + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/api/chat')); }); diff --git a/tests/Feature/Providers/Ollama/StreamingTest.php b/tests/Feature/Providers/Ollama/StreamingTest.php index c0c1b0ad3..5ba11e18d 100644 --- a/tests/Feature/Providers/Ollama/StreamingTest.php +++ b/tests/Feature/Providers/Ollama/StreamingTest.php @@ -12,14 +12,14 @@ use Laravel\Ai\Streaming\Events\ToolCall as ToolCallEvent; use Tests\Fixtures\Agents\ProviderOptionsWithToolsAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.ollama' => [ ...config('ai.providers.ollama'), 'key' => '', ]]); }); -test('streaming emits text events', function () { +test('streaming emits text events', function (): void { Http::fake([ '*' => Http::response( body: $this->ndjsonPayload([ @@ -42,7 +42,7 @@ ->and($events[count($events) - 1])->toBeInstanceOf(StreamEnd::class); }); -test('streaming request sets stream to true', function () { +test('streaming request sets stream to true', function (): void { Http::fake([ '*' => Http::response( body: $this->ndjsonPayload([ @@ -55,14 +55,14 @@ $this->collectStreamEvents(); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['stream'] === true; }); }); -test('streaming handles tool calls', function () { +test('streaming handles tool calls', function (): void { Http::fake([ '*' => Http::sequence([ Http::response( @@ -85,14 +85,14 @@ $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); - $toolCallEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolCallEvent)); + $toolCallEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolCallEvent)); expect($toolCallEvents)->not->toBeEmpty() ->and($toolCallEvents[0]->toolCall->name)->toBe('FixedNumberGenerator') ->and($toolCallEvents[0]->toolCall->id)->toBe('call_1'); }); -test('streaming error event stops stream with string payload', function () { +test('streaming error event stops stream with string payload', function (): void { Http::fake([ '*' => Http::response( body: json_encode(['error' => 'model not found'])."\n", @@ -108,7 +108,7 @@ ->and($events[0]->message)->toBe('model not found'); }); -test('streaming error event also handles structured payload', function () { +test('streaming error event also handles structured payload', function (): void { Http::fake([ '*' => Http::response( body: json_encode(['error' => ['code' => 'model_error', 'message' => 'Model not found']])."\n", @@ -124,7 +124,7 @@ ->and($events[0]->message)->toBe('Model not found'); }); -test('streaming accumulates tool call arguments across chunks', function () { +test('streaming accumulates tool call arguments across chunks', function (): void { Http::fake([ '*' => Http::sequence([ Http::response( @@ -190,7 +190,7 @@ $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); - $toolCallEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolCallEvent)); + $toolCallEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolCallEvent)); expect($toolCallEvents)->toHaveCount(1) ->and($toolCallEvents[0]->toolCall->id)->toBe('call_1') @@ -198,7 +198,7 @@ ->and($toolCallEvents[0]->toolCall->arguments)->toBe(['foo' => 'bar']); }); -test('streaming generates fallback id when tool call has no id', function () { +test('streaming generates fallback id when tool call has no id', function (): void { Http::fake([ '*' => Http::sequence([ Http::response( @@ -236,14 +236,14 @@ $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); - $toolCallEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolCallEvent)); + $toolCallEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolCallEvent)); expect($toolCallEvents)->toHaveCount(1) ->and($toolCallEvents[0]->toolCall->name)->toBe('FixedNumberGenerator') ->and($toolCallEvents[0]->toolCall->id)->not->toBeEmpty(); }); -test('streaming captures usage from final chunk', function () { +test('streaming captures usage from final chunk', function (): void { Http::fake([ '*' => Http::response( body: $this->ndjsonPayload([ @@ -256,13 +256,13 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->usage->promptTokens)->toBe(42) ->and($streamEnd->usage->completionTokens)->toBe(10); }); -test('streaming emits exactly one stream end across a tool loop', function () { +test('streaming emits exactly one stream end across a tool loop', function (): void { Http::fake([ '*' => Http::sequence([ Http::response( @@ -285,7 +285,7 @@ $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); - $streamEnds = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd)); + $streamEnds = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd)); expect($streamEnds)->toHaveCount(1) ->and($streamEnds[0])->toBe($events[count($events) - 1]) @@ -294,7 +294,7 @@ ->and($streamEnds[0]->usage->completionTokens)->toBe(15); }); -test('streaming finish reason maps correctly', function (string $doneReason, $expected) { +test('streaming finish reason maps correctly', function (string $doneReason, $expected): void { Http::fake([ '*' => Http::response( body: $this->ndjsonPayload([ @@ -308,7 +308,7 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->reason)->toBe($expected->value); })->with([ diff --git a/tests/Feature/Providers/Ollama/ToolCallLoopTest.php b/tests/Feature/Providers/Ollama/ToolCallLoopTest.php index d20cdf270..546c74d93 100644 --- a/tests/Feature/Providers/Ollama/ToolCallLoopTest.php +++ b/tests/Feature/Providers/Ollama/ToolCallLoopTest.php @@ -3,17 +3,18 @@ use GuzzleHttp\Promise\PromiseInterface; use Illuminate\Support\Facades\Http; use Laravel\Ai\Exceptions\NoSuchToolException; +use Laravel\Ai\Responses\AgentResponse; use Tests\Fixtures\Agents\MultiStepToolAgent; use Tests\Fixtures\Agents\ToolUsingAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.ollama' => [ ...config('ai.providers.ollama'), 'key' => '', ]]); }); -test('tool calls trigger follow up request', function () { +test('tool calls trigger follow up request', function (): void { Http::fake([ '*' => Http::sequence([ fakeUniqueOllamaToolCallResponse(), @@ -30,13 +31,13 @@ expect($recorded)->toHaveCount(2); - $followUpMessages = collect(json_decode($recorded[1][0]->body(), true)['messages']); + $followUpMessages = collect(json_decode((string) $recorded[1][0]->body(), true)['messages']); - expect($followUpMessages->contains(fn ($m) => $m['role'] === 'assistant' && isset($m['tool_calls'])))->toBeTrue() - ->and($followUpMessages->contains(fn ($m) => $m['role'] === 'tool'))->toBeTrue(); + expect($followUpMessages->contains(fn ($m): bool => $m['role'] === 'assistant' && isset($m['tool_calls'])))->toBeTrue() + ->and($followUpMessages->contains(fn ($m): bool => $m['role'] === 'tool'))->toBeTrue(); }); -test('tool result message uses tool_name field', function () { +test('tool result message uses tool_name field', function (): void { Http::fake([ '*' => Http::sequence([ fakeUniqueOllamaToolCallResponse(), @@ -50,9 +51,9 @@ ); $recorded = Http::recorded(); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); - $toolMsg = collect($followUpBody['messages'])->first(fn ($m) => $m['role'] === 'tool'); + $toolMsg = collect($followUpBody['messages'])->first(fn ($m): bool => $m['role'] === 'tool'); expect($toolMsg)->not->toBeNull() ->and($toolMsg)->toHaveKey('tool_name') @@ -60,7 +61,7 @@ ->and($toolMsg)->not->toHaveKey('tool_call_id'); }); -test('max steps limits tool call depth', function () { +test('max steps limits tool call depth', function (): void { Http::fake([ '*' => Http::sequence([ fakeUniqueOllamaToolCallResponse(), @@ -80,7 +81,7 @@ expect(count($recorded))->toBeLessThanOrEqual(3); }); -test('tool calls without id are executed with a generated id', function () { +test('tool calls without id are executed with a generated id', function (): void { Http::fake([ '*' => Http::sequence([ Http::response([ @@ -114,15 +115,15 @@ expect($recorded)->toHaveCount(2); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); - $toolMsg = collect($followUpBody['messages'])->first(fn ($m) => $m['role'] === 'tool'); + $toolMsg = collect($followUpBody['messages'])->first(fn ($m): bool => $m['role'] === 'tool'); expect($toolMsg)->not->toBeNull() ->and($toolMsg['tool_name'])->toBe('FixedNumberGenerator'); }); -test('tool calls are executed even when done_reason is stop', function () { +test('tool calls are executed even when done_reason is stop', function (): void { Http::fake([ '*' => Http::sequence([ Http::response([ @@ -160,7 +161,7 @@ ->and($response->text)->toBe('The number is 72019'); }); -test('multi step tool loop returns accumulated response shape', function () { +test('multi step tool loop returns accumulated response shape', function (): void { Http::fake([ '*' => Http::sequence([ fakeUniqueOllamaToolCallResponse(), @@ -183,7 +184,7 @@ ->and($response->usage->completionTokens)->toBe(11); }); -test('unregistered tool call throws NoSuchToolException', function () { +test('unregistered tool call throws NoSuchToolException', function (): void { Http::fake([ '*' => Http::response([ 'model' => 'llama3.1:8b', @@ -205,7 +206,7 @@ ]), ]); - expect(fn () => (new MultiStepToolAgent)->prompt( + expect(fn (): AgentResponse => (new MultiStepToolAgent)->prompt( 'Generate a number', provider: 'ollama', ))->toThrow(NoSuchToolException::class); diff --git a/tests/Feature/Providers/Ollama/ToolMappingTest.php b/tests/Feature/Providers/Ollama/ToolMappingTest.php index fdac458b9..d549a1421 100644 --- a/tests/Feature/Providers/Ollama/ToolMappingTest.php +++ b/tests/Feature/Providers/Ollama/ToolMappingTest.php @@ -8,21 +8,21 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.ollama' => [ ...config('ai.providers.ollama'), 'key' => '', ]]); }); -test('tool with parameters includes correct schema', function () { +test('tool with parameters includes correct schema', function (): void { Http::fake([ '*' => $this->fakeTextResponse('42'), ]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a random number', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; @@ -35,14 +35,14 @@ }); }); -test('tool with empty schema includes parameters', function () { +test('tool with empty schema includes parameters', function (): void { Http::fake([ '*' => $this->fakeTextResponse('72019'), ]); agent(tools: [new FixedNumberGenerator])->prompt('Give me a number', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; @@ -52,12 +52,12 @@ }); }); -test('tool with a name() method emits the declared name', function () { +test('tool with a name() method emits the declared name', function (): void { Http::fake(['*' => $this->fakeTextResponse('ok')]); agent(tools: [new NamedTool('my_custom_tool')])->prompt('Hi', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $names = collect(data_get($body, 'tools'))->pluck('function.name')->all(); @@ -65,14 +65,14 @@ }); }); -test('tool definition includes name and description', function () { +test('tool definition includes name and description', function (): void { Http::fake([ '*' => $this->fakeTextResponse('done'), ]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a number', provider: 'ollama'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; diff --git a/tests/Feature/Providers/OpenAi/AgentFakeTest.php b/tests/Feature/Providers/OpenAi/AgentFakeTest.php index 4997e86f2..bd0a566b6 100644 --- a/tests/Feature/Providers/OpenAi/AgentFakeTest.php +++ b/tests/Feature/Providers/OpenAi/AgentFakeTest.php @@ -2,7 +2,7 @@ use Tests\Fixtures\Agents\OpenAiAgent; -test('openai agent can be faked', function () { +test('openai agent can be faked', function (): void { OpenAiAgent::fake(['Test response']); $response = (new OpenAiAgent)->prompt('Hello'); @@ -10,15 +10,15 @@ expect($response->text)->toBe('Test response'); }); -test('openai agent fake with closure', function () { - OpenAiAgent::fake(fn (string $prompt) => "Echo: {$prompt}"); +test('openai agent fake with closure', function (): void { + OpenAiAgent::fake(fn (string $prompt): string => "Echo: {$prompt}"); $response = (new OpenAiAgent)->prompt('Hello world'); expect($response->text)->toBe('Echo: Hello world'); }); -test('openai agent fake with no predefined responses', function () { +test('openai agent fake with no predefined responses', function (): void { OpenAiAgent::fake(); $response = (new OpenAiAgent)->prompt('Hello'); @@ -26,7 +26,7 @@ expect($response->text)->toBe('Fake response for prompt: Hello'); }); -test('openai agent fake records prompts', function () { +test('openai agent fake records prompts', function (): void { OpenAiAgent::fake(); (new OpenAiAgent)->prompt('Hello'); @@ -35,11 +35,11 @@ OpenAiAgent::assertNotPrompted('Goodbye'); }); -test('openai agent stream can be faked', function () { +test('openai agent stream can be faked', function (): void { OpenAiAgent::fake(['Streamed response']); $response = (new OpenAiAgent)->stream('Hello'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->text)->toBe('Streamed response'); }); diff --git a/tests/Feature/Providers/OpenAi/AudioTest.php b/tests/Feature/Providers/OpenAi/AudioTest.php index 9af23f424..a468c3a85 100644 --- a/tests/Feature/Providers/OpenAi/AudioTest.php +++ b/tests/Feature/Providers/OpenAi/AudioTest.php @@ -7,7 +7,7 @@ use Laravel\Ai\Audio; use Laravel\Ai\Exceptions\RateLimitedException; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', @@ -19,12 +19,12 @@ function fakeOpenAiAudioResponse(): PromiseInterface return Http::response('fake-audio-bytes'); } -test('audio request includes model, input, voice, response format, and speed', function () { +test('audio request includes model, input, voice, response format, and speed', function (): void { Http::fake(['*' => fakeOpenAiAudioResponse()]); Audio::of('Hello world')->generate(provider: 'openai', model: 'gpt-4o-mini-tts'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'gpt-4o-mini-tts' @@ -36,47 +36,39 @@ function fakeOpenAiAudioResponse(): PromiseInterface }); }); -test('audio request resolves default-female voice to alloy', function () { +test('audio request resolves default-female voice to alloy', function (): void { Http::fake(['*' => fakeOpenAiAudioResponse()]); Audio::of('Hello')->female()->generate(provider: 'openai', model: 'gpt-4o-mini-tts'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['voice'] === 'alloy'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['voice'] === 'alloy'); }); -test('audio request resolves default-male voice to ash', function () { +test('audio request resolves default-male voice to ash', function (): void { Http::fake(['*' => fakeOpenAiAudioResponse()]); Audio::of('Hello')->male()->generate(provider: 'openai', model: 'gpt-4o-mini-tts'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['voice'] === 'ash'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['voice'] === 'ash'); }); -test('audio request passes custom voice id through unchanged', function () { +test('audio request passes custom voice id through unchanged', function (): void { Http::fake(['*' => fakeOpenAiAudioResponse()]); Audio::of('Hello')->voice('nova')->generate(provider: 'openai', model: 'gpt-4o-mini-tts'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['voice'] === 'nova'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['voice'] === 'nova'); }); -test('audio request includes instructions when provided', function () { +test('audio request includes instructions when provided', function (): void { Http::fake(['*' => fakeOpenAiAudioResponse()]); Audio::of('Hello')->instructions('Speak slowly')->generate(provider: 'openai', model: 'gpt-4o-mini-tts'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['instructions'] === 'Speak slowly'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['instructions'] === 'Speak slowly'); }); -test('audio response is base64-encoded with audio/mpeg mime type', function () { +test('audio response is base64-encoded with audio/mpeg mime type', function (): void { Http::fake(['*' => fakeOpenAiAudioResponse()]); $response = Audio::of('Hello')->generate(provider: 'openai', model: 'gpt-4o-mini-tts'); @@ -87,15 +79,15 @@ function fakeOpenAiAudioResponse(): PromiseInterface ->and($response->meta->model)->toBe('gpt-4o-mini-tts'); }); -test('audio uses default model when none specified', function () { +test('audio uses default model when none specified', function (): void { Http::fake(['*' => fakeOpenAiAudioResponse()]); Audio::of('Hello')->generate(provider: 'openai'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['model'] === 'gpt-4o-mini-tts'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['model'] === 'gpt-4o-mini-tts'); }); -test('audio rate limit response throws rate limited exception', function () { +test('audio rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ @@ -108,7 +100,7 @@ function fakeOpenAiAudioResponse(): PromiseInterface Audio::of('Hello')->generate(provider: 'openai', model: 'gpt-4o-mini-tts'); })->throws(RateLimitedException::class); -test('audio http error response throws request exception', function () { +test('audio http error response throws request exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ @@ -121,7 +113,7 @@ function fakeOpenAiAudioResponse(): PromiseInterface Audio::of('Hello')->generate(provider: 'openai', model: 'gpt-4o-mini-tts'); })->throws(RequestException::class); -test('audio request sends bearer token', function () { +test('audio request sends bearer token', function (): void { Http::fake(['*' => fakeOpenAiAudioResponse()]); Audio::of('Hello')->generate(provider: 'openai', model: 'gpt-4o-mini-tts'); diff --git a/tests/Feature/Providers/OpenAi/BaseUrlTest.php b/tests/Feature/Providers/OpenAi/BaseUrlTest.php index 0460ec5ce..8eb8c4ab8 100644 --- a/tests/Feature/Providers/OpenAi/BaseUrlTest.php +++ b/tests/Feature/Providers/OpenAi/BaseUrlTest.php @@ -8,11 +8,11 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { $this->customUrl = 'http://localhost:1234/v1'; }); -test('openai text requests use the configured base url', function () { +test('openai text requests use the configured base url', function (): void { configureOpenAiProvider($this->customUrl); Http::fake([ @@ -43,16 +43,14 @@ openAiAssertRequestSent('POST', "{$this->customUrl}/responses"); }); -test('openai file requests use the configured base url', function () { +test('openai file requests use the configured base url', function (): void { configureOpenAiProvider($this->customUrl); - Http::fake(function (Request $request) { - return match ([$request->method(), $request->url()]) { - ['POST', "{$this->customUrl}/files"] => Http::response(['id' => 'file_123']), - ['GET', "{$this->customUrl}/files/file_123"] => Http::response(['id' => 'file_123']), - ['DELETE', "{$this->customUrl}/files/file_123"] => Http::response(), - default => Http::response(['unexpected_url' => $request->url()], 500), - }; + Http::fake(fn (Request $request) => match ([$request->method(), $request->url()]) { + ['POST', "{$this->customUrl}/files"] => Http::response(['id' => 'file_123']), + ['GET', "{$this->customUrl}/files/file_123"] => Http::response(['id' => 'file_123']), + ['DELETE', "{$this->customUrl}/files/file_123"] => Http::response(), + default => Http::response(['unexpected_url' => $request->url()], 500), }); $stored = Files::put( @@ -73,27 +71,25 @@ openAiAssertRequestSent('DELETE', "{$this->customUrl}/files/file_123"); }); -test('openai store requests use the configured base url', function () { +test('openai store requests use the configured base url', function (): void { configureOpenAiProvider($this->customUrl); - Http::fake(function (Request $request) { - return match ([$request->method(), $request->url()]) { - ['POST', "{$this->customUrl}/vector_stores"] => Http::response(['id' => 'vs_123']), - ['GET', "{$this->customUrl}/vector_stores/vs_123"] => Http::response([ - 'id' => 'vs_123', - 'name' => 'Local Store', - 'status' => 'completed', - 'file_counts' => [ - 'completed' => 0, - 'in_progress' => 0, - 'failed' => 0, - ], - ]), - ['POST', "{$this->customUrl}/vector_stores/vs_123/files"] => Http::response(['id' => 'vsfile_123']), - ['DELETE', "{$this->customUrl}/vector_stores/vs_123/files/vsfile_123"] => Http::response(['deleted' => true]), - ['DELETE', "{$this->customUrl}/vector_stores/vs_123"] => Http::response(['deleted' => true]), - default => Http::response(['unexpected_url' => $request->url()], 500), - }; + Http::fake(fn (Request $request) => match ([$request->method(), $request->url()]) { + ['POST', "{$this->customUrl}/vector_stores"] => Http::response(['id' => 'vs_123']), + ['GET', "{$this->customUrl}/vector_stores/vs_123"] => Http::response([ + 'id' => 'vs_123', + 'name' => 'Local Store', + 'status' => 'completed', + 'file_counts' => [ + 'completed' => 0, + 'in_progress' => 0, + 'failed' => 0, + ], + ]), + ['POST', "{$this->customUrl}/vector_stores/vs_123/files"] => Http::response(['id' => 'vsfile_123']), + ['DELETE', "{$this->customUrl}/vector_stores/vs_123/files/vsfile_123"] => Http::response(['deleted' => true]), + ['DELETE', "{$this->customUrl}/vector_stores/vs_123"] => Http::response(['deleted' => true]), + default => Http::response(['unexpected_url' => $request->url()], 500), }); $store = Stores::create('Local Store', provider: 'openai'); @@ -115,7 +111,7 @@ openAiAssertRequestSent('DELETE', "{$this->customUrl}/vector_stores/vs_123"); }); -test('openai requests fall back to the default base url', function () { +test('openai requests fall back to the default base url', function (): void { configureOpenAiProvider(); Http::fake([ @@ -157,6 +153,6 @@ function configureOpenAiProvider(?string $url = null): void function openAiAssertRequestSent(string $method, string $url): void { - Http::assertSent(fn (Request $request) => $request->method() === $method + Http::assertSent(fn (Request $request): bool => $request->method() === $method && $request->url() === $url); } diff --git a/tests/Feature/Providers/OpenAi/EmbeddingTest.php b/tests/Feature/Providers/OpenAi/EmbeddingTest.php index 33f765826..6486241dc 100644 --- a/tests/Feature/Providers/OpenAi/EmbeddingTest.php +++ b/tests/Feature/Providers/OpenAi/EmbeddingTest.php @@ -8,7 +8,7 @@ use Laravel\Ai\Exceptions\ProviderOverloadedException; use Laravel\Ai\Exceptions\RateLimitedException; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', @@ -26,12 +26,12 @@ function fakeOpenAiEmbeddingResponse(): PromiseInterface ]); } -test('embeddings request includes model, input, and dimensions', function () { +test('embeddings request includes model, input, and dimensions', function (): void { Http::fake(['*' => fakeOpenAiEmbeddingResponse()]); Embeddings::for(['Hello world'])->dimensions(768)->generate(provider: 'openai', model: 'text-embedding-3-small'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'text-embedding-3-small' @@ -41,7 +41,7 @@ function fakeOpenAiEmbeddingResponse(): PromiseInterface }); }); -test('embeddings response is correctly parsed', function () { +test('embeddings response is correctly parsed', function (): void { Http::fake(['*' => fakeOpenAiEmbeddingResponse()]); $response = Embeddings::for(['Hello world'])->generate(provider: 'openai', model: 'text-embedding-3-small'); @@ -53,7 +53,7 @@ function fakeOpenAiEmbeddingResponse(): PromiseInterface ->and($response->meta->model)->toBe('text-embedding-3-small'); }); -test('multiple inputs return multiple embeddings', function () { +test('multiple inputs return multiple embeddings', function (): void { Http::fake(['*' => Http::response([ 'object' => 'list', 'data' => [ @@ -69,7 +69,7 @@ function fakeOpenAiEmbeddingResponse(): PromiseInterface ->and($response->embeddings[1])->toBe([0.4, 0.5, 0.6]); }); -test('embeddings request sends bearer token', function () { +test('embeddings request sends bearer token', function (): void { Http::fake(['*' => fakeOpenAiEmbeddingResponse()]); Embeddings::for(['Hello'])->generate(provider: 'openai', model: 'text-embedding-3-small'); @@ -77,30 +77,30 @@ function fakeOpenAiEmbeddingResponse(): PromiseInterface Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('embeddings use default model when none specified', function () { +test('embeddings use default model when none specified', function (): void { Http::fake(['*' => fakeOpenAiEmbeddingResponse()]); Embeddings::for(['Hello'])->generate(provider: 'openai'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['model'] === 'text-embedding-3-small'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['model'] === 'text-embedding-3-small'); }); -test('embeddings default to 1536 dimensions when none specified', function () { +test('embeddings default to 1536 dimensions when none specified', function (): void { Http::fake(['*' => fakeOpenAiEmbeddingResponse()]); Embeddings::for(['Hello'])->generate(provider: 'openai', model: 'text-embedding-3-small'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['dimensions'] === 1536); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['dimensions'] === 1536); }); -test('embeddings request includes provider options in the request body', function () { +test('embeddings request includes provider options in the request body', function (): void { Http::fake(['*' => fakeOpenAiEmbeddingResponse()]); Embeddings::for(['Hello']) ->withProviderOptions(['encoding_format' => 'base64', 'user' => 'tester']) ->generate(provider: 'openai', model: 'text-embedding-3-small'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['encoding_format'] === 'base64' @@ -109,14 +109,14 @@ function fakeOpenAiEmbeddingResponse(): PromiseInterface }); }); -test('provider options cannot override framework controlled keys', function () { +test('provider options cannot override framework controlled keys', function (): void { Http::fake(['*' => fakeOpenAiEmbeddingResponse()]); Embeddings::for(['Hello']) ->withProviderOptions(['model' => 'hijacked', 'input' => ['hijacked'], 'dimensions' => 1]) ->generate(provider: 'openai', model: 'text-embedding-3-small'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'text-embedding-3-small' @@ -125,7 +125,7 @@ function fakeOpenAiEmbeddingResponse(): PromiseInterface }); }); -test('embeddings rate limit response throws rate limited exception', function () { +test('embeddings rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ @@ -138,7 +138,7 @@ function fakeOpenAiEmbeddingResponse(): PromiseInterface Embeddings::for(['Hello'])->generate(provider: 'openai', model: 'text-embedding-3-small'); })->throws(RateLimitedException::class); -test('embeddings overloaded response throws provider overloaded exception', function () { +test('embeddings overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ @@ -151,7 +151,7 @@ function fakeOpenAiEmbeddingResponse(): PromiseInterface Embeddings::for(['Hello'])->generate(provider: 'openai', model: 'text-embedding-3-small'); })->throws(ProviderOverloadedException::class); -test('embeddings http error response throws request exception', function () { +test('embeddings http error response throws request exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ diff --git a/tests/Feature/Providers/OpenAi/EncryptedReasoningTest.php b/tests/Feature/Providers/OpenAi/EncryptedReasoningTest.php index 1a7d96080..a50295d3f 100644 --- a/tests/Feature/Providers/OpenAi/EncryptedReasoningTest.php +++ b/tests/Feature/Providers/OpenAi/EncryptedReasoningTest.php @@ -7,7 +7,7 @@ use Tests\Fixtures\Agents\ProviderOptionsWithToolsAgent; use Tests\Fixtures\Agents\ToolUsingAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', @@ -15,22 +15,22 @@ ]]); }); -test('initial request includes store false and reasoning encrypted content in include', function () { +test('initial request includes store false and reasoning encrypted content in include', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse('Hello'), ]); (new OpenAiAgent)->prompt('Hello'); - Http::assertSent(function ($request) { - $body = json_decode($request->body(), true); + Http::assertSent(function ($request): bool { + $body = json_decode((string) $request->body(), true); return ($body['store'] ?? null) === false && in_array('reasoning.encrypted_content', $body['include'] ?? [], true); }); }); -test('tool follow up omits previous response id and echoes encrypted reasoning back inline', function () { +test('tool follow up omits previous response id and echoes encrypted reasoning back inline', function (): void { Http::fake([ 'api.openai.com/*' => Http::sequence([ fakeOpenAiToolCallResponseWithEncryptedReasoning('rs_1', 'enc-blob-1', 'fc_1', 'call_1'), @@ -43,7 +43,7 @@ $recorded = Http::recorded(); expect($recorded)->toHaveCount(2); - $followUp = json_decode($recorded[1][0]->body(), true); + $followUp = json_decode((string) $recorded[1][0]->body(), true); expect($followUp)->not->toHaveKey('previous_response_id') ->and($followUp['store'] ?? null)->toBeFalse() @@ -51,22 +51,22 @@ $input = collect($followUp['input']); - expect($input->contains(fn ($i) => ($i['role'] ?? null) === 'user' - && collect($i['content'] ?? [])->contains(fn ($c) => ($c['text'] ?? '') === 'Generate a number'))) + expect($input->contains(fn ($i): bool => ($i['role'] ?? null) === 'user' + && collect($i['content'] ?? [])->contains(fn ($c): bool => ($c['text'] ?? '') === 'Generate a number'))) ->toBeTrue('original user message resent inline') - ->and($input->contains(fn ($i) => ($i['type'] ?? null) === 'reasoning' + ->and($input->contains(fn ($i): bool => ($i['type'] ?? null) === 'reasoning' && ($i['id'] ?? null) === 'rs_1' && ($i['encrypted_content'] ?? null) === 'enc-blob-1')) ->toBeTrue('reasoning block with encrypted_content round-tripped') - ->and($input->contains(fn ($i) => ($i['type'] ?? null) === 'function_call' + ->and($input->contains(fn ($i): bool => ($i['type'] ?? null) === 'function_call' && ($i['call_id'] ?? null) === 'call_1')) ->toBeTrue('assistant function call resent') - ->and($input->contains(fn ($i) => ($i['type'] ?? null) === 'function_call_output' + ->and($input->contains(fn ($i): bool => ($i['type'] ?? null) === 'function_call_output' && ($i['call_id'] ?? null) === 'call_1')) ->toBeTrue('tool result included'); }); -test('multi step tool loop accumulates encrypted reasoning across steps', function () { +test('multi step tool loop accumulates encrypted reasoning across steps', function (): void { Http::fake([ 'api.openai.com/*' => Http::sequence([ fakeOpenAiToolCallResponseWithEncryptedReasoning('rs_1', 'enc-blob-1', 'fc_1', 'call_1'), @@ -80,20 +80,20 @@ $recorded = Http::recorded(); expect($recorded)->toHaveCount(3); - $finalFollowUp = json_decode($recorded[2][0]->body(), true); + $finalFollowUp = json_decode((string) $recorded[2][0]->body(), true); expect($finalFollowUp)->not->toHaveKey('previous_response_id'); $input = collect($finalFollowUp['input']); - expect($input->where(fn ($i) => ($i['type'] ?? null) === 'reasoning' + expect($input->where(fn ($i): bool => ($i['type'] ?? null) === 'reasoning' && ($i['encrypted_content'] ?? null) === 'enc-blob-1')->count())->toBe(1) - ->and($input->where(fn ($i) => ($i['type'] ?? null) === 'reasoning' + ->and($input->where(fn ($i): bool => ($i['type'] ?? null) === 'reasoning' && ($i['encrypted_content'] ?? null) === 'enc-blob-2')->count())->toBe(1) - ->and($input->where(fn ($i) => ($i['type'] ?? null) === 'function_call_output')->count())->toBe(2); + ->and($input->where(fn ($i): bool => ($i['type'] ?? null) === 'function_call_output')->count())->toBe(2); }); -test('streaming tool follow up echoes encrypted reasoning back inline', function () { +test('streaming tool follow up echoes encrypted reasoning back inline', function (): void { Http::fake([ 'api.openai.com/*' => Http::sequence([ Http::response( @@ -143,7 +143,7 @@ $recorded = Http::recorded(); expect($recorded)->toHaveCount(2); - $followUp = json_decode($recorded[1][0]->body(), true); + $followUp = json_decode((string) $recorded[1][0]->body(), true); expect($followUp)->not->toHaveKey('previous_response_id') ->and($followUp['store'] ?? null)->toBeFalse() @@ -151,25 +151,25 @@ $input = collect($followUp['input']); - expect($input->contains(fn ($i) => ($i['type'] ?? null) === 'reasoning' + expect($input->contains(fn ($i): bool => ($i['type'] ?? null) === 'reasoning' && ($i['id'] ?? null) === 'rs_1' && ($i['encrypted_content'] ?? null) === 'enc-blob-1')) ->toBeTrue('streamed reasoning block with encrypted_content round-tripped') - ->and($input->contains(fn ($i) => ($i['type'] ?? null) === 'function_call' + ->and($input->contains(fn ($i): bool => ($i['type'] ?? null) === 'function_call' && ($i['call_id'] ?? null) === 'call_1')) ->toBeTrue('streamed function call resent') - ->and($input->contains(fn ($i) => ($i['type'] ?? null) === 'function_call_output' + ->and($input->contains(fn ($i): bool => ($i['type'] ?? null) === 'function_call_output' && ($i['call_id'] ?? null) === 'call_1')) ->toBeTrue('streamed tool result included'); }); -test('non-reasoning model omits reasoning.encrypted_content include even with store false', function (string $model) { +test('non-reasoning model omits reasoning.encrypted_content include even with store false', function (string $model): void { Http::fake(['api.openai.com/*' => fakeOpenAiResponse()]); (new OpenAiAgent)->prompt('Hi', model: $model); - Http::assertSent(function ($request) { - $body = json_decode($request->body(), true); + Http::assertSent(function ($request): bool { + $body = json_decode((string) $request->body(), true); return ($body['store'] ?? null) === false && ! in_array('reasoning.encrypted_content', $body['include'] ?? [], true); @@ -180,7 +180,7 @@ 'gpt-5-chat-latest', ]); -test('store accepts env-style string values', function (mixed $storeValue, bool $shouldBeStateless) { +test('store accepts env-style string values', function (mixed $storeValue, bool $shouldBeStateless): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'store' => $storeValue, @@ -190,8 +190,8 @@ (new OpenAiAgent)->prompt('Hi'); - Http::assertSent(function ($request) use ($shouldBeStateless) { - $body = json_decode($request->body(), true); + Http::assertSent(function ($request) use ($shouldBeStateless): bool { + $body = json_decode((string) $request->body(), true); $isStateless = ($body['store'] ?? null) === false; return $isStateless === $shouldBeStateless; @@ -206,7 +206,7 @@ 'unrecognized string' => ['maybe', false], ]); -test('default store true preserves previous response id behaviour', function () { +test('default store true preserves previous response id behaviour', function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'store' => true, @@ -222,7 +222,7 @@ (new ToolUsingAgent(fixed: true))->prompt('Generate a number', provider: 'openai'); $recorded = Http::recorded(); - $followUp = json_decode($recorded[1][0]->body(), true); + $followUp = json_decode((string) $recorded[1][0]->body(), true); expect($followUp)->toHaveKey('previous_response_id') ->and($followUp['previous_response_id'])->toBe('resp_tool_1') @@ -234,8 +234,8 @@ expect($input)->toHaveCount(1) ->and($input->first()['type'] ?? null)->toBe('function_call_output') ->and($input->first()['call_id'] ?? null)->toBe('call_1') - ->and($input->contains(fn ($i) => ($i['role'] ?? null) === 'user'))->toBeFalse() - ->and($input->contains(fn ($i) => ($i['type'] ?? null) === 'reasoning'))->toBeFalse(); + ->and($input->contains(fn ($i): bool => ($i['role'] ?? null) === 'user'))->toBeFalse() + ->and($input->contains(fn ($i): bool => ($i['type'] ?? null) === 'reasoning'))->toBeFalse(); }); function fakeOpenAiToolCallResponseWithEncryptedReasoning(string $reasoningId, string $encryptedContent, string $functionCallId, string $callId): PromiseInterface diff --git a/tests/Feature/Providers/OpenAi/ErrorHandlingTest.php b/tests/Feature/Providers/OpenAi/ErrorHandlingTest.php index 0b2487e6c..31be6612a 100644 --- a/tests/Feature/Providers/OpenAi/ErrorHandlingTest.php +++ b/tests/Feature/Providers/OpenAi/ErrorHandlingTest.php @@ -7,14 +7,14 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Tests\Fixtures\Agents\AssistantAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', ]]); }); -test('http error response throws request exception', function () { +test('http error response throws request exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ @@ -30,7 +30,7 @@ ); })->throws(RequestException::class); -test('rate limit response throws rate limited exception', function () { +test('rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ @@ -46,7 +46,7 @@ ); })->throws(RateLimitedException::class); -test('overloaded response throws provider overloaded exception', function () { +test('overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ @@ -62,7 +62,7 @@ ); })->throws(ProviderOverloadedException::class); -test('error in 200 response throws ai exception', function () { +test('error in 200 response throws ai exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ @@ -78,7 +78,7 @@ ); })->throws(AiException::class, 'OpenAI Error'); -test('failed status response throws ai exception', function () { +test('failed status response throws ai exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'id' => 'resp_123', diff --git a/tests/Feature/Providers/OpenAi/FileGatewayTest.php b/tests/Feature/Providers/OpenAi/FileGatewayTest.php index 28c532f19..83cc91a0e 100644 --- a/tests/Feature/Providers/OpenAi/FileGatewayTest.php +++ b/tests/Feature/Providers/OpenAi/FileGatewayTest.php @@ -6,14 +6,14 @@ use Laravel\Ai\Files; use Laravel\Ai\Files\Document; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', ]]); }); -test('get file sends correct request', function () { +test('get file sends correct request', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(['id' => 'file-abc123']), ]); @@ -22,14 +22,12 @@ expect($response->id)->toBe('file-abc123'); - Http::assertSent(function (Request $request) { - return $request->method() === 'GET' - && $request->url() === 'https://api.openai.com/v1/files/file-abc123' - && $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request): bool => $request->method() === 'GET' + && $request->url() === 'https://api.openai.com/v1/files/file-abc123' + && $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('put file sends multipart upload with user_data purpose', function () { +test('put file sends multipart upload with user_data purpose', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(['id' => 'file-uploaded123']), ]); @@ -49,7 +47,7 @@ ->and($request->hasHeader('Authorization', 'Bearer test-key'))->toBeTrue(); }); -test('put file allows overriding the purpose via provider options', function () { +test('put file allows overriding the purpose via provider options', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(['id' => 'file-uploaded123']), ]); @@ -65,13 +63,13 @@ ->and(multipartField($request, 'purpose'))->toBe('fine-tune'); }); -test('put file resolves provider options from a closure scoped to the provider', function () { +test('put file resolves provider options from a closure scoped to the provider', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(['id' => 'file-uploaded123']), ]); Document::fromString('Hello, World!', 'text/plain')->as('hello.txt') - ->withProviderOptions(fn (Lab $provider) => match ($provider) { + ->withProviderOptions(fn (Lab $provider): array => match ($provider) { Lab::OpenAI => ['purpose' => 'assistants'], default => [], }) @@ -80,16 +78,14 @@ expect(multipartField(sentRequest(), 'purpose'))->toBe('assistants'); }); -test('delete file sends correct request', function () { +test('delete file sends correct request', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(['id' => 'file-abc123', 'deleted' => true]), ]); Files::delete('file-abc123', provider: 'openai'); - Http::assertSent(function (Request $request) { - return $request->method() === 'DELETE' - && $request->url() === 'https://api.openai.com/v1/files/file-abc123' - && $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' + && $request->url() === 'https://api.openai.com/v1/files/file-abc123' + && $request->hasHeader('Authorization', 'Bearer test-key')); }); diff --git a/tests/Feature/Providers/OpenAi/ImageGenerationTest.php b/tests/Feature/Providers/OpenAi/ImageGenerationTest.php index 2c95b462b..8ec6e5402 100644 --- a/tests/Feature/Providers/OpenAi/ImageGenerationTest.php +++ b/tests/Feature/Providers/OpenAi/ImageGenerationTest.php @@ -8,7 +8,7 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Laravel\Ai\Image; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', @@ -24,14 +24,14 @@ function fakeOpenAiImageResponse(): PromiseInterface ]); } -test('image request does not include quality when not specified', function () { +test('image request does not include quality when not specified', function (): void { Http::fake([ '*' => fakeOpenAiImageResponse(), ]); Image::of('A red apple')->generate(provider: 'openai', model: 'dall-e-2'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'dall-e-2' @@ -39,14 +39,14 @@ function fakeOpenAiImageResponse(): PromiseInterface }); }); -test('image request does not include moderation for non gpt-image models', function () { +test('image request does not include moderation for non gpt-image models', function (): void { Http::fake([ '*' => fakeOpenAiImageResponse(), ]); Image::of('A red apple')->generate(provider: 'openai', model: 'dall-e-3'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'dall-e-3' @@ -54,14 +54,14 @@ function fakeOpenAiImageResponse(): PromiseInterface }); }); -test('image request includes moderation low for gpt-image models', function () { +test('image request includes moderation low for gpt-image models', function (): void { Http::fake([ '*' => fakeOpenAiImageResponse(), ]); Image::of('A red apple')->generate(provider: 'openai', model: 'gpt-image-1'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'gpt-image-1' @@ -69,14 +69,14 @@ function fakeOpenAiImageResponse(): PromiseInterface }); }); -test('image request includes quality when explicitly specified', function () { +test('image request includes quality when explicitly specified', function (): void { Http::fake([ '*' => fakeOpenAiImageResponse(), ]); Image::of('A red apple')->quality('high')->generate(provider: 'openai', model: 'dall-e-3'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['quality'] === 'high' @@ -84,35 +84,35 @@ function fakeOpenAiImageResponse(): PromiseInterface }); }); -test('image request includes size when specified', function () { +test('image request includes size when specified', function (): void { Http::fake([ '*' => fakeOpenAiImageResponse(), ]); Image::of('A red apple')->square()->generate(provider: 'openai', model: 'gpt-image-1'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['size'] === '1024x1024'; }); }); -test('image request does not include size when not specified', function () { +test('image request does not include size when not specified', function (): void { Http::fake([ '*' => fakeOpenAiImageResponse(), ]); Image::of('A red apple')->generate(provider: 'openai', model: 'gpt-image-1'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('size', $body); }); }); -test('image response includes usage tokens when returned by gpt-image', function () { +test('image response includes usage tokens when returned by gpt-image', function (): void { Http::fake([ '*' => Http::response([ 'data' => [[ @@ -135,7 +135,7 @@ function fakeOpenAiImageResponse(): PromiseInterface ->and($response->usage->completionTokens)->toBe(1024); }); -test('image response subtracts cached tokens from prompt tokens', function () { +test('image response subtracts cached tokens from prompt tokens', function (): void { Http::fake([ '*' => Http::response([ 'data' => [[ @@ -159,7 +159,7 @@ function fakeOpenAiImageResponse(): PromiseInterface ->and($response->usage->completionTokens)->toBe(1024); }); -test('image response defaults to zero usage when not returned by dalle', function () { +test('image response defaults to zero usage when not returned by dalle', function (): void { Http::fake([ '*' => fakeOpenAiImageResponse(), ]); @@ -170,35 +170,35 @@ function fakeOpenAiImageResponse(): PromiseInterface ->and($response->usage->completionTokens)->toBe(0); }); -test('image generation request adds response_format b64_json for dall-e models', function () { +test('image generation request adds response_format b64_json for dall-e models', function (): void { Http::fake([ '*' => fakeOpenAiImageResponse(), ]); Image::of('A red apple')->generate(provider: 'openai', model: 'dall-e-3'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ($body['response_format'] ?? null) === 'b64_json'; }); }); -test('image generation request omits response_format for gpt-image models', function () { +test('image generation request omits response_format for gpt-image models', function (): void { Http::fake([ '*' => fakeOpenAiImageResponse(), ]); Image::of('A red apple')->generate(provider: 'openai', model: 'gpt-image-1'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('response_format', $body); }); }); -test('image rate limit response throws rate limited exception', function () { +test('image rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ @@ -211,7 +211,7 @@ function fakeOpenAiImageResponse(): PromiseInterface Image::of('A red apple')->generate(provider: 'openai', model: 'gpt-image-1'); })->throws(RateLimitedException::class); -test('image overloaded response throws provider overloaded exception', function () { +test('image overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ @@ -224,7 +224,7 @@ function fakeOpenAiImageResponse(): PromiseInterface Image::of('A red apple')->generate(provider: 'openai', model: 'gpt-image-1'); })->throws(ProviderOverloadedException::class); -test('image http error response throws request exception', function () { +test('image http error response throws request exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ diff --git a/tests/Feature/Providers/OpenAi/MessageMappingTest.php b/tests/Feature/Providers/OpenAi/MessageMappingTest.php index fe20fab64..ceac0f7d9 100644 --- a/tests/Feature/Providers/OpenAi/MessageMappingTest.php +++ b/tests/Feature/Providers/OpenAi/MessageMappingTest.php @@ -17,14 +17,14 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', ]]); }); -test('user message maps to openai format', function () { +test('user message maps to openai format', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse(), ]); @@ -34,7 +34,7 @@ provider: 'openai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $input = $body['input']; $userMessage = collect($input)->firstWhere('role', 'user'); @@ -45,7 +45,7 @@ }); }); -test('tool result follow up uses previous response id', function () { +test('tool result follow up uses previous response id', function (): void { Http::fake([ 'api.openai.com/*' => Http::sequence([ fakeOpenAiToolCallResponse(), @@ -62,7 +62,7 @@ expect($recorded)->toHaveCount(2); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); expect($followUpBody)->toHaveKey('previous_response_id') ->and($followUpBody['previous_response_id'])->toBe('resp_tool_123'); @@ -80,7 +80,7 @@ expect($hasFunctionCallOutput)->toBeTrue(); }); -test('base64 pdf document maps to input file', function () { +test('base64 pdf document maps to input file', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse('I see a PDF'), ]); @@ -93,7 +93,7 @@ provider: 'openai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['input'])->firstWhere('role', 'user'); $content = $userMessage['content']; @@ -101,12 +101,12 @@ $fileBlock = collect($content)->firstWhere('type', 'input_file'); return $fileBlock !== null - && str_contains($fileBlock['file_data'], 'application/pdf') - && str_contains($fileBlock['file_data'], base64_encode('fake-pdf-content')); + && str_contains((string) $fileBlock['file_data'], 'application/pdf') + && str_contains((string) $fileBlock['file_data'], base64_encode('fake-pdf-content')); }); }); -test('nameless text document falls back to derived filename', function () { +test('nameless text document falls back to derived filename', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse(), ]); @@ -117,7 +117,7 @@ provider: 'openai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['input'])->firstWhere('role', 'user'); $fileBlock = collect($userMessage['content'])->firstWhere('type', 'input_file'); @@ -127,7 +127,7 @@ }); }); -test('uploaded pdf file maps to input file', function () { +test('uploaded pdf file maps to input file', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse('I see a PDF'), ]); @@ -140,7 +140,7 @@ provider: 'openai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['input'])->firstWhere('role', 'user'); $content = $userMessage['content']; @@ -148,11 +148,11 @@ $fileBlock = collect($content)->firstWhere('type', 'input_file'); return $fileBlock !== null - && str_contains($fileBlock['file_data'], 'application/pdf'); + && str_contains((string) $fileBlock['file_data'], 'application/pdf'); }); }); -test('local image attachment without explicit mime type detects mime from file', function () { +test('local image attachment without explicit mime type detects mime from file', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse('I see an image'), ]); @@ -163,25 +163,24 @@ provider: 'openai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['input'])->firstWhere('role', 'user'); $imageBlock = collect($userMessage['content'])->firstWhere('type', 'input_image'); return $imageBlock !== null - && str_starts_with($imageBlock['image_url'], 'data:image/png;base64,') - && ! str_contains($imageBlock['image_url'], 'data:;base64,'); + && str_starts_with((string) $imageBlock['image_url'], 'data:image/png;base64,') + && ! str_contains((string) $imageBlock['image_url'], 'data:;base64,'); }); }); -test('empty tool arguments serialize as object string on assistant replay', function () { +test('empty tool arguments serialize as object string on assistant replay', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse('hi'), ]); agent( instructions: 'Hi.', - tools: [(new ToolUsingAgent(fixed: true))->tools()[0]], messages: [ new UserMessage('list'), new AssistantMessage('Listing.', collect([ @@ -203,9 +202,10 @@ ])), new UserMessage('thanks'), ], + tools: [(new ToolUsingAgent(fixed: true))->tools()[0]], )->prompt('', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $fnCall = collect($body['input'] ?? []) ->firstWhere('type', 'function_call'); @@ -214,14 +214,13 @@ }); }); -test('non-empty tool arguments preserve shape on assistant replay', function () { +test('non-empty tool arguments preserve shape on assistant replay', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse('hi'), ]); agent( instructions: 'Hi.', - tools: [(new ToolUsingAgent(fixed: true))->tools()[0]], messages: [ new UserMessage('search'), new AssistantMessage('Searching.', collect([ @@ -243,25 +242,25 @@ ])), new UserMessage('thanks'), ], + tools: [(new ToolUsingAgent(fixed: true))->tools()[0]], )->prompt('', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $fnCall = collect($body['input'] ?? []) ->firstWhere('type', 'function_call'); - return $fnCall && json_decode($fnCall['arguments'], true) === ['query' => 'test']; + return $fnCall && json_decode((string) $fnCall['arguments'], true) === ['query' => 'test']; }); }); -test('reasoning blocks are interleaved with associated tool calls on assistant replay', function () { +test('reasoning blocks are interleaved with associated tool calls on assistant replay', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse('hi'), ]); agent( instructions: 'Hi.', - tools: [(new ToolUsingAgent(fixed: true))->tools()[0]], messages: [ new UserMessage('search'), new AssistantMessage('Searching.', collect([ @@ -299,17 +298,18 @@ ])), new UserMessage('thanks'), ], + tools: [(new ToolUsingAgent(fixed: true))->tools()[0]], )->prompt('', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $input = $body['input']; - $rs1Index = collect($input)->search(fn ($i) => ($i['type'] ?? '') === 'reasoning' && ($i['id'] ?? '') === 'rs_1'); - $call1Index = collect($input)->search(fn ($i) => ($i['id'] ?? '') === 'call_1'); - $rs2Index = collect($input)->search(fn ($i) => ($i['type'] ?? '') === 'reasoning' && ($i['id'] ?? '') === 'rs_2'); - $call2Index = collect($input)->search(fn ($i) => ($i['id'] ?? '') === 'call_2'); - $call3Index = collect($input)->search(fn ($i) => ($i['id'] ?? '') === 'call_3'); + $rs1Index = collect($input)->search(fn ($i): bool => ($i['type'] ?? '') === 'reasoning' && ($i['id'] ?? '') === 'rs_1'); + $call1Index = collect($input)->search(fn ($i): bool => ($i['id'] ?? '') === 'call_1'); + $rs2Index = collect($input)->search(fn ($i): bool => ($i['type'] ?? '') === 'reasoning' && ($i['id'] ?? '') === 'rs_2'); + $call2Index = collect($input)->search(fn ($i): bool => ($i['id'] ?? '') === 'call_2'); + $call3Index = collect($input)->search(fn ($i): bool => ($i['id'] ?? '') === 'call_3'); return $rs1Index !== false && $call1Index !== false @@ -321,7 +321,7 @@ }); }); -test('image attachment provider options are forwarded to the content part', function () { +test('image attachment provider options are forwarded to the content part', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse('I see an image'), ]); @@ -335,18 +335,18 @@ provider: 'openai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['input'])->firstWhere('role', 'user'); $imageBlock = collect($userMessage['content'])->firstWhere('type', 'input_image'); return $imageBlock !== null && ($imageBlock['detail'] ?? null) === 'low' - && str_starts_with($imageBlock['image_url'], 'data:image/png;base64,'); + && str_starts_with((string) $imageBlock['image_url'], 'data:image/png;base64,'); }); }); -test('document attachment provider options are forwarded to the content part', function () { +test('document attachment provider options are forwarded to the content part', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse('I see a document'), ]); @@ -360,24 +360,24 @@ provider: 'openai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['input'])->firstWhere('role', 'user'); $fileBlock = collect($userMessage['content'])->firstWhere('type', 'input_file'); return $fileBlock !== null && ($fileBlock['detail'] ?? null) === 'high' - && str_contains($fileBlock['file_data'], base64_encode('hello world')); + && str_contains((string) $fileBlock['file_data'], base64_encode('hello world')); }); }); -test('attachment provider options resolve from a closure scoped to the provider', function () { +test('attachment provider options resolve from a closure scoped to the provider', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse('I see an image'), ]); $image = (new LocalImage(__DIR__.'/../../../Fixtures/Images/red.png')) - ->withProviderOptions(fn (Lab $provider) => match ($provider) { + ->withProviderOptions(fn (Lab $provider): array => match ($provider) { Lab::OpenAI => ['detail' => 'low'], default => [], }); @@ -388,7 +388,7 @@ provider: 'openai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['input'])->firstWhere('role', 'user'); $imageBlock = collect($userMessage['content'])->firstWhere('type', 'input_image'); @@ -398,7 +398,7 @@ }); }); -test('attachments without provider options map unchanged', function () { +test('attachments without provider options map unchanged', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse('I see an image'), ]); @@ -409,7 +409,7 @@ provider: 'openai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['input'])->firstWhere('role', 'user'); $imageBlock = collect($userMessage['content'])->firstWhere('type', 'input_image'); @@ -419,7 +419,7 @@ }); }); -test('provider options cannot overwrite the mapped structural keys', function () { +test('provider options cannot overwrite the mapped structural keys', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse('I see an image'), ]); @@ -433,7 +433,7 @@ provider: 'openai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMessage = collect($body['input'])->firstWhere('role', 'user'); $imageBlock = collect($userMessage['content'])->firstWhere('type', 'input_image'); @@ -441,11 +441,11 @@ return $imageBlock !== null && $imageBlock['type'] === 'input_image' && ($imageBlock['detail'] ?? null) === 'low' - && str_starts_with($imageBlock['image_url'], 'data:image/png;base64,'); + && str_starts_with((string) $imageBlock['image_url'], 'data:image/png;base64,'); }); }); -test('system instructions are in input array as system role', function () { +test('system instructions are in input array as system role', function (): void { Http::fake([ 'api.openai.com/*' => fakeOpenAiResponse(), ]); @@ -455,11 +455,11 @@ provider: 'openai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $systemMsg = collect($body['input'])->firstWhere('role', 'system'); return $systemMsg !== null - && str_contains($systemMsg['content'], 'helpful assistant'); + && str_contains((string) $systemMsg['content'], 'helpful assistant'); }); }); diff --git a/tests/Feature/Providers/OpenAi/ProviderOptionsTest.php b/tests/Feature/Providers/OpenAi/ProviderOptionsTest.php index 8fcab17d3..70a6b1ec8 100644 --- a/tests/Feature/Providers/OpenAi/ProviderOptionsTest.php +++ b/tests/Feature/Providers/OpenAi/ProviderOptionsTest.php @@ -7,21 +7,21 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', ]]); }); -test('provider options are included in openai request body', function () { +test('provider options are included in openai request body', function (): void { Http::fake([ '*' => fakeOpenAiResponse('Hello'), ]); (new ProviderOptionsAgent)->prompt('Hello', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'reasoning.effort') === 'high' @@ -30,14 +30,14 @@ }); }); -test('request body does not contain provider options when agent does not implement interface', function () { +test('request body does not contain provider options when agent does not implement interface', function (): void { Http::fake([ '*' => fakeOpenAiResponse('Hello'), ]); agent()->prompt('Hello', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('reasoning', $body) @@ -46,7 +46,7 @@ }); }); -test('provider options are persisted in tool call follow up requests', function () { +test('provider options are persisted in tool call follow up requests', function (): void { Http::fake([ '*' => Http::sequence([ fakeOpenAiToolCallResponse(), @@ -56,11 +56,11 @@ (new ProviderOptionsWithToolsAgent)->prompt('Give me a number', provider: 'openai'); - $requests = Http::recorded(fn (Request $r) => true); + $requests = Http::recorded(fn (Request $r): true => true); expect(count($requests))->toBeGreaterThanOrEqual(2); - $followUpBody = json_decode($requests[1][0]->body(), true); + $followUpBody = json_decode((string) $requests[1][0]->body(), true); expect(data_get($followUpBody, 'reasoning.effort'))->toBe('high') ->and(data_get($followUpBody, 'frequency_penalty'))->toBe(0.5) diff --git a/tests/Feature/Providers/OpenAi/RequestMappingTest.php b/tests/Feature/Providers/OpenAi/RequestMappingTest.php index 497d00bdb..33336c288 100644 --- a/tests/Feature/Providers/OpenAi/RequestMappingTest.php +++ b/tests/Feature/Providers/OpenAi/RequestMappingTest.php @@ -12,48 +12,48 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', ]]); }); -test('request includes model and input', function () { +test('request includes model and input', function (): void { Http::fake(['*' => fakeOpenAiResponse('Hello')]); agent()->prompt('Hi there', provider: 'openai', model: 'gpt-5.4'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'gpt-5.4' && is_array($body['input']) - && collect($body['input'])->contains(fn ($m) => $m['role'] === 'user' - && collect($m['content'])->contains(fn ($c) => ($c['text'] ?? '') === 'Hi there')); + && collect($body['input'])->contains(fn ($m): bool => $m['role'] === 'user' + && collect($m['content'])->contains(fn ($c): bool => ($c['text'] ?? '') === 'Hi there')); }); }); -test('system instructions are sent as system message in input', function () { +test('system instructions are sent as system message in input', function (): void { Http::fake(['*' => fakeOpenAiResponse('Hello')]); (new AssistantAgent)->prompt('Hello', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $systemMsg = collect($body['input'])->firstWhere('role', 'system'); return $systemMsg !== null - && str_contains($systemMsg['content'], 'helpful assistant'); + && str_contains((string) $systemMsg['content'], 'helpful assistant'); }); }); -test('temperature, max tokens, and top_p are included when set via attributes', function () { +test('temperature, max tokens, and top_p are included when set via attributes', function (): void { Http::fake(['*' => fakeOpenAiResponse('Hello')]); (new AttributeAgent)->prompt('Hello', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'temperature') === 0.7 @@ -62,12 +62,12 @@ }); }); -test('temperature, max tokens, and top_p are excluded when not set', function () { +test('temperature, max tokens, and top_p are excluded when not set', function (): void { Http::fake(['*' => fakeOpenAiResponse('Hello')]); agent()->prompt('Hello', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('temperature', $body) @@ -76,26 +76,26 @@ }); }); -test('tools include tool choice auto', function () { +test('tools include tool choice auto', function (): void { Http::fake(['*' => fakeOpenAiResponse('42')]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a number', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['tool_choice'] === 'auto' && is_array($body['tools']) - && count($body['tools']) > 0; + && $body['tools'] !== []; }); }); -test('request without tools excludes tool fields', function () { +test('request without tools excludes tool fields', function (): void { Http::fake(['*' => fakeOpenAiResponse('Hello')]); agent()->prompt('Hello', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('tools', $body) @@ -103,12 +103,12 @@ }); }); -test('structured output includes json schema text format', function () { +test('structured output includes json schema text format', function (): void { Http::fake(['*' => fakeOpenAiResponse('{"symbol": "Au"}')]); (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $format = data_get($body, 'text.format'); @@ -119,12 +119,12 @@ }); }); -test('structured agent without Strict attribute sends strict false in text format', function () { +test('structured agent without Strict attribute sends strict false in text format', function (): void { Http::fake(['*' => fakeOpenAiResponse('{"elements": []}')]); (new NestedStructuredAgent)->prompt('List elements.', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $format = data_get($body, 'text.format'); @@ -133,29 +133,27 @@ }); }); -test('request without schema excludes text format', function () { +test('request without schema excludes text format', function (): void { Http::fake(['*' => fakeOpenAiResponse('Hello')]); agent()->prompt('Hello', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('text', $body); }); }); -test('request sends bearer token authorization', function () { +test('request sends bearer token authorization', function (): void { Http::fake(['*' => fakeOpenAiResponse('Hello')]); agent()->prompt('Hello', provider: 'openai'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('response text is correctly parsed', function () { +test('response text is correctly parsed', function (): void { Http::fake(['*' => fakeOpenAiResponse('Laravel is great')]); $response = agent()->prompt('Tell me about Laravel', provider: 'openai'); @@ -164,7 +162,7 @@ ->and($response->meta->provider)->toBe('openai'); }); -test('response usage is correctly parsed', function () { +test('response usage is correctly parsed', function (): void { Http::fake(['*' => Http::response([ 'id' => 'resp_123', 'status' => 'completed', @@ -189,7 +187,7 @@ ->and($response->usage->completionTokens)->toBe(5); }); -test('structured response is correctly parsed', function () { +test('structured response is correctly parsed', function (): void { Http::fake(['*' => fakeOpenAiResponse('{"symbol": "Au"}')]); $response = (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'openai'); @@ -197,7 +195,7 @@ expect($response->structured['symbol'])->toBe('Au'); }); -test('citations preserve every annotation with span indices', function () { +test('citations preserve every annotation with span indices', function (): void { Http::fake(['*' => Http::response([ 'id' => 'resp_123', 'status' => 'completed', @@ -251,7 +249,7 @@ ->and($response->meta->citations[2]->startIndex)->toBe(26); }); -test('citations omit span indices when not provided by the api', function () { +test('citations omit span indices when not provided by the api', function (): void { Http::fake(['*' => Http::response([ 'id' => 'resp_123', 'status' => 'completed', @@ -284,45 +282,37 @@ ->and($response->meta->citations[0]->endIndex)->toBeNull(); }); -test('required tool choice forces the model to call a tool', function () { +test('required tool choice forces the model to call a tool', function (): void { Http::fake(['*' => fakeOpenAiResponse('42')]); (new ToolChoiceAgent('required'))->prompt('Give me a number', provider: 'openai'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['tool_choice'] === 'required'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'required'); }); -test('required tool choice can be set via attribute', function () { +test('required tool choice can be set via attribute', function (): void { Http::fake(['*' => fakeOpenAiResponse('42')]); (new AttributeToolChoiceAgent)->prompt('Give me a number', provider: 'openai'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['tool_choice'] === 'required'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'required'); }); -test('named tool choice forces a specific function', function () { +test('named tool choice forces a specific function', function (): void { Http::fake(['*' => fakeOpenAiResponse('42')]); (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Give me a number', provider: 'openai'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['tool_choice'] === [ - 'type' => 'function', - 'name' => 'custom_named_tool', - ]; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === [ + 'type' => 'function', + 'name' => 'custom_named_tool', + ]); }); -test('none tool choice prevents tool calls', function () { +test('none tool choice prevents tool calls', function (): void { Http::fake(['*' => fakeOpenAiResponse('Sure')]); (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'openai'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['tool_choice'] === 'none'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'none'); }); diff --git a/tests/Feature/Providers/OpenAi/StoreGatewayTest.php b/tests/Feature/Providers/OpenAi/StoreGatewayTest.php index 504696992..00b16d6b2 100644 --- a/tests/Feature/Providers/OpenAi/StoreGatewayTest.php +++ b/tests/Feature/Providers/OpenAi/StoreGatewayTest.php @@ -7,7 +7,7 @@ use function Illuminate\Support\days; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', @@ -33,7 +33,7 @@ function fakeOpenAiStoreResponse(string $id = 'vs-123', string $name = 'Test Sto ]; } -test('get store sends correct request', function () { +test('get store sends correct request', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(fakeOpenAiStoreResponse()), ]); @@ -47,14 +47,12 @@ function fakeOpenAiStoreResponse(string $id = 'vs-123', string $name = 'Test Sto ->and($store->fileCounts->failed)->toBe(0) ->and($store->ready)->toBeTrue(); - Http::assertSent(function (Request $request) { - return $request->method() === 'GET' - && $request->url() === 'https://api.openai.com/v1/vector_stores/vs-123' - && $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request): bool => $request->method() === 'GET' + && $request->url() === 'https://api.openai.com/v1/vector_stores/vs-123' + && $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('create store sends correct request', function () { +test('create store sends correct request', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(fakeOpenAiStoreResponse()), ]); @@ -64,48 +62,42 @@ function fakeOpenAiStoreResponse(string $id = 'vs-123', string $name = 'Test Sto expect($store->id)->toBe('vs-123') ->and($store->name)->toBe('Test Store'); - Http::assertSent(function (Request $request) { - return $request->method() === 'POST' - && $request->url() === 'https://api.openai.com/v1/vector_stores' - && ($request->data()['name'] ?? null) === 'Test Store'; - }); + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && $request->url() === 'https://api.openai.com/v1/vector_stores' + && ($request->data()['name'] ?? null) === 'Test Store'); }); -test('create store maps description to metadata', function () { +test('create store maps description to metadata', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(fakeOpenAiStoreResponse()), ]); Stores::create('Test Store', description: 'A test store', provider: 'openai'); - Http::assertSent(function (Request $request) { - return $request->method() === 'POST' - && ! array_key_exists('description', $request->data()) - && ($request->data()['metadata'] ?? null) === ['description' => 'A test store']; - }); + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && ! array_key_exists('description', $request->data()) + && ($request->data()['metadata'] ?? null) === ['description' => 'A test store']); }); -test('create store includes file ids in request', function () { +test('create store includes file ids in request', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(fakeOpenAiStoreResponse()), ]); Stores::create('Test Store', fileIds: ['file-1', 'file-2'], provider: 'openai'); - Http::assertSent(function (Request $request) { - return $request->method() === 'POST' - && ($request->data()['file_ids'] ?? null) === ['file-1', 'file-2']; - }); + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && ($request->data()['file_ids'] ?? null) === ['file-1', 'file-2']); }); -test('create store includes expiration when provided', function () { +test('create store includes expiration when provided', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(fakeOpenAiStoreResponse()), ]); Stores::create('Expiring Store', expiresWhenIdleFor: days(7), provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { if ($request->method() !== 'POST') { return false; } @@ -117,7 +109,7 @@ function fakeOpenAiStoreResponse(string $id = 'vs-123', string $name = 'Test Sto }); }); -test('add file sends correct request', function () { +test('add file sends correct request', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(['id' => 'doc-456']), ]); @@ -127,14 +119,12 @@ function fakeOpenAiStoreResponse(string $id = 'vs-123', string $name = 'Test Sto expect($documentId)->toBe('doc-456'); - Http::assertSent(function (Request $request) { - return $request->method() === 'POST' - && $request->url() === 'https://api.openai.com/v1/vector_stores/vs-123/files' - && ($request->data()['file_id'] ?? null) === 'file-789'; - }); + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && $request->url() === 'https://api.openai.com/v1/vector_stores/vs-123/files' + && ($request->data()['file_id'] ?? null) === 'file-789'); }); -test('add file with metadata includes attributes', function () { +test('add file with metadata includes attributes', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(['id' => 'doc-456']), ]); @@ -142,12 +132,10 @@ function fakeOpenAiStoreResponse(string $id = 'vs-123', string $name = 'Test Sto $provider = openAiProvider(); $provider->storeGateway()->addFile($provider, 'vs-123', 'file-789', ['company' => 'laravel']); - Http::assertSent(function (Request $request) { - return ($request->data()['attributes'] ?? null) === ['company' => 'laravel']; - }); + Http::assertSent(fn (Request $request): bool => ($request->data()['attributes'] ?? null) === ['company' => 'laravel']); }); -test('remove file sends correct request', function () { +test('remove file sends correct request', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(['deleted' => true]), ]); @@ -157,13 +145,11 @@ function fakeOpenAiStoreResponse(string $id = 'vs-123', string $name = 'Test Sto expect($result)->toBeTrue(); - Http::assertSent(function (Request $request) { - return $request->method() === 'DELETE' - && $request->url() === 'https://api.openai.com/v1/vector_stores/vs-123/files/doc-456'; - }); + Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' + && $request->url() === 'https://api.openai.com/v1/vector_stores/vs-123/files/doc-456'); }); -test('delete store sends correct request', function () { +test('delete store sends correct request', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(['deleted' => true]), ]); @@ -172,9 +158,7 @@ function fakeOpenAiStoreResponse(string $id = 'vs-123', string $name = 'Test Sto expect($result)->toBeTrue(); - Http::assertSent(function (Request $request) { - return $request->method() === 'DELETE' - && $request->url() === 'https://api.openai.com/v1/vector_stores/vs-123' - && $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' + && $request->url() === 'https://api.openai.com/v1/vector_stores/vs-123' + && $request->hasHeader('Authorization', 'Bearer test-key')); }); diff --git a/tests/Feature/Providers/OpenAi/StreamingTest.php b/tests/Feature/Providers/OpenAi/StreamingTest.php index 3c2885409..a102dc61b 100644 --- a/tests/Feature/Providers/OpenAi/StreamingTest.php +++ b/tests/Feature/Providers/OpenAi/StreamingTest.php @@ -14,14 +14,14 @@ use Laravel\Ai\Streaming\Events\ToolCall as ToolCallEvent; use Tests\Fixtures\Agents\ProviderOptionsWithToolsAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', ]]); }); -test('streaming emits text events', function () { +test('streaming emits text events', function (): void { Http::fake([ 'api.openai.com/*' => Http::response( body: $this->ssePayload([ @@ -46,7 +46,7 @@ ->and($events[count($events) - 1])->toBeInstanceOf(StreamEnd::class); }); -test('streaming handles tool calls', function () { +test('streaming handles tool calls', function (): void { Http::fake([ 'api.openai.com/*' => Http::sequence([ Http::response( @@ -80,8 +80,8 @@ $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); - $toolCallEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolCallEvent)); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $toolCallEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolCallEvent)); + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($toolCallEvents)->not->toBeEmpty() ->and($toolCallEvents[0]->toolCall->name)->toBe('FixedNumberGenerator') @@ -91,7 +91,7 @@ ->and($streamEnd->usage->completionTokens)->toBe(15); }); -test('streaming handles reasoning events', function () { +test('streaming handles reasoning events', function (): void { Http::fake([ 'api.openai.com/*' => Http::response( body: $this->ssePayload([ @@ -118,11 +118,11 @@ ->toContain(ReasoningDelta::class) ->toContain(ReasoningEnd::class); - $reasoningDelta = array_values(array_filter($events, fn ($e) => $e instanceof ReasoningDelta))[0]; + $reasoningDelta = array_values(array_filter($events, fn ($e): bool => $e instanceof ReasoningDelta))[0]; expect($reasoningDelta->delta)->toBe('Let me think...'); }); -test('streaming error event stops stream', function () { +test('streaming error event stops stream', function (): void { Http::fake([ 'api.openai.com/*' => Http::response( body: $this->ssePayload([ @@ -141,7 +141,7 @@ ->and($events[0]->message)->toBe('Server overloaded'); }); -test('streaming captures usage from response completed', function () { +test('streaming captures usage from response completed', function (): void { Http::fake([ 'api.openai.com/*' => Http::response( body: $this->ssePayload([ @@ -157,14 +157,14 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->usage->promptTokens)->toBe(37) ->and($streamEnd->usage->completionTokens)->toBe(10) ->and($streamEnd->usage->cacheReadInputTokens)->toBe(5); }); -test('streaming finish reason maps correctly', function (string $status, string $type, $expected) { +test('streaming finish reason maps correctly', function (string $status, string $type, $expected): void { Http::fake([ 'api.openai.com/*' => Http::response( body: $this->ssePayload([ @@ -182,7 +182,7 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->reason)->toBe($expected->value); })->with([ diff --git a/tests/Feature/Providers/OpenAi/ToolCallLoopTest.php b/tests/Feature/Providers/OpenAi/ToolCallLoopTest.php index 8dc3b6afe..f9389e90d 100644 --- a/tests/Feature/Providers/OpenAi/ToolCallLoopTest.php +++ b/tests/Feature/Providers/OpenAi/ToolCallLoopTest.php @@ -6,14 +6,14 @@ use Tests\Fixtures\Agents\ToolChoiceAgent; use Tests\Fixtures\Agents\ToolUsingAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', ]]); }); -test('tool calls trigger follow up request', function () { +test('tool calls trigger follow up request', function (): void { Http::fake([ 'api.openai.com/*' => Http::sequence([ fakeUniqueOpenAiToolCallResponse(), @@ -30,7 +30,7 @@ expect($recorded)->toHaveCount(2); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); expect($followUpBody)->toHaveKey('previous_response_id'); @@ -45,7 +45,7 @@ expect($hasFunctionCallOutput)->toBeTrue(); }); -test('max steps limits tool call depth', function () { +test('max steps limits tool call depth', function (): void { Http::fake([ 'api.openai.com/*' => Http::sequence([ fakeUniqueOpenAiToolCallResponse(), @@ -65,7 +65,7 @@ expect(count($recorded))->toBeLessThanOrEqual(3); }); -test('multi step tool loop returns accumulated response shape', function () { +test('multi step tool loop returns accumulated response shape', function (): void { Http::fake([ 'api.openai.com/*' => Http::sequence([ fakeUniqueOpenAiToolCallResponse(), @@ -88,7 +88,7 @@ ->and($response->usage->completionTokens)->toBe(11); }); -test('a forced tool choice is released on the follow up request', function () { +test('a forced tool choice is released on the follow up request', function (): void { Http::fake([ 'api.openai.com/*' => Http::sequence([ fakeOpenAiRandomNumberToolCallResponse(), @@ -101,8 +101,8 @@ $recorded = Http::recorded(); expect($recorded)->toHaveCount(2) - ->and(json_decode($recorded[0][0]->body(), true)['tool_choice'])->toBe('required') - ->and(json_decode($recorded[1][0]->body(), true)['tool_choice'])->toBe('auto'); + ->and(json_decode((string) $recorded[0][0]->body(), true)['tool_choice'])->toBe('required') + ->and(json_decode((string) $recorded[1][0]->body(), true)['tool_choice'])->toBe('auto'); }); function fakeOpenAiRandomNumberToolCallResponse(): PromiseInterface diff --git a/tests/Feature/Providers/OpenAi/ToolMappingTest.php b/tests/Feature/Providers/OpenAi/ToolMappingTest.php index 6d54ec234..bb72d7995 100644 --- a/tests/Feature/Providers/OpenAi/ToolMappingTest.php +++ b/tests/Feature/Providers/OpenAi/ToolMappingTest.php @@ -11,21 +11,21 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', ]]); }); -test('tool with parameters includes strict compliant schema', function () { +test('tool with parameters includes strict compliant schema', function (): void { Http::fake([ '*' => fakeOpenAiResponse('42'), ]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a random number', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); @@ -39,14 +39,14 @@ }); }); -test('tool with a name() method emits the declared name', function () { +test('tool with a name() method emits the declared name', function (): void { Http::fake([ '*' => fakeOpenAiResponse('ok'), ]); agent(tools: [new NamedTool('my_custom_tool')])->prompt('Hi', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $names = collect(data_get($body, 'tools'))->pluck('name')->all(); @@ -54,14 +54,14 @@ }); }); -test('tool without a name() method falls back to class basename for openai', function () { +test('tool without a name() method falls back to class basename for openai', function (): void { Http::fake([ '*' => fakeOpenAiResponse('ok'), ]); agent(tools: [new FixedNumberGenerator])->prompt('Hi', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $names = collect(data_get($body, 'tools'))->pluck('name')->all(); @@ -69,14 +69,14 @@ }); }); -test('tool without Strict attribute sends strict false and honors developer-declared required fields', function () { +test('tool without Strict attribute sends strict false and honors developer-declared required fields', function (): void { Http::fake([ '*' => fakeOpenAiResponse('ok'), ]); agent(tools: [new NonStrictTool])->prompt('Hi', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); @@ -86,14 +86,14 @@ }); }); -test('tool with empty schema includes strict compliant parameters', function () { +test('tool with empty schema includes strict compliant parameters', function (): void { Http::fake([ '*' => fakeOpenAiResponse('72019'), ]); agent(tools: [new FixedNumberGenerator])->prompt('Give me a random number', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); @@ -106,14 +106,14 @@ }); }); -test('web search tool sends type web_search', function () { +test('web search tool sends type web_search', function (): void { Http::fake([ '*' => fakeOpenAiResponse('result'), ]); agent(tools: [new WebSearch])->prompt('Search the web', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'web_search'); @@ -121,14 +121,14 @@ }); }); -test('web search tool omits openai-specific options by default', function () { +test('web search tool omits openai-specific options by default', function (): void { Http::fake([ '*' => fakeOpenAiResponse('result'), ]); agent(tools: [new WebSearch])->prompt('Search the web', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'web_search'); @@ -136,7 +136,7 @@ }); }); -test('web search tool forwards openai provider options into the tool payload', function () { +test('web search tool forwards openai provider options into the tool payload', function (): void { Http::fake([ '*' => fakeOpenAiResponse('result'), ]); @@ -148,7 +148,7 @@ ]), ])->prompt('Search', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'web_search'); @@ -157,18 +157,18 @@ }); }); -test('web search tool ignores provider options keyed to another provider', function () { +test('web search tool ignores provider options keyed to another provider', function (): void { Http::fake([ '*' => fakeOpenAiResponse('result'), ]); agent(tools: [ - (new WebSearch)->withProviderOptions(fn (Lab|string $provider) => $provider === Lab::Anthropic + (new WebSearch)->withProviderOptions(fn (Lab|string $provider): array => $provider === Lab::Anthropic ? ['external_web_access' => false] : []), ])->prompt('Search', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'web_search'); @@ -176,7 +176,7 @@ }); }); -test('web search tool sends allowed_domains filter', function () { +test('web search tool sends allowed_domains filter', function (): void { Http::fake([ '*' => fakeOpenAiResponse('result'), ]); @@ -184,7 +184,7 @@ agent(tools: [(new WebSearch)->allow(['example.com', 'docs.example.com'])]) ->prompt('Search', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'web_search'); @@ -192,7 +192,7 @@ }); }); -test('web search tool sends blocked_domains via provider options', function () { +test('web search tool sends blocked_domains via provider options', function (): void { Http::fake([ '*' => fakeOpenAiResponse('result'), ]); @@ -203,7 +203,7 @@ ]), ])->prompt('Search', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'web_search'); @@ -211,7 +211,7 @@ }); }); -test('web search tool merges allow() with blocked_domains provider option', function () { +test('web search tool merges allow() with blocked_domains provider option', function (): void { Http::fake([ '*' => fakeOpenAiResponse('result'), ]); @@ -222,7 +222,7 @@ ->withProviderOptions(['filters' => ['blocked_domains' => ['bad.com']]]), ])->prompt('Search', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'web_search'); @@ -231,14 +231,14 @@ }); }); -test('web search tool omits filters when no domains configured', function () { +test('web search tool omits filters when no domains configured', function (): void { Http::fake([ '*' => fakeOpenAiResponse('result'), ]); agent(tools: [new WebSearch])->prompt('Search', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'web_search'); @@ -246,7 +246,7 @@ }); }); -test('web search tool sends user_location when location is set', function () { +test('web search tool sends user_location when location is set', function (): void { Http::fake([ '*' => fakeOpenAiResponse('result'), ]); @@ -254,7 +254,7 @@ agent(tools: [(new WebSearch)->location(city: 'Warsaw', country: 'PL')]) ->prompt('Search', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'web_search'); @@ -264,14 +264,14 @@ }); }); -test('web search tool omits user_location when no location set', function () { +test('web search tool omits user_location when no location set', function (): void { Http::fake([ '*' => fakeOpenAiResponse('result'), ]); agent(tools: [new WebSearch])->prompt('Search', provider: 'openai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'web_search'); diff --git a/tests/Feature/Providers/OpenAi/TranscriptionTest.php b/tests/Feature/Providers/OpenAi/TranscriptionTest.php index 4292fb3c3..c638d65a8 100644 --- a/tests/Feature/Providers/OpenAi/TranscriptionTest.php +++ b/tests/Feature/Providers/OpenAi/TranscriptionTest.php @@ -7,28 +7,26 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Laravel\Ai\Transcription; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openai' => [ ...config('ai.providers.openai'), 'key' => 'test-key', ]]); }); -test('transcription sends prompt from provider options', function () { +test('transcription sends prompt from provider options', function (): void { Http::fake(['*' => fakeOpenAiTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->withProviderOptions(['prompt' => 'Laravel Forge and Vapor']) ->generate(provider: 'openai', model: 'gpt-4o-transcribe'); - Http::assertSent(function (Request $request) { - return $request->url() === 'https://api.openai.com/v1/audio/transcriptions' - && str_contains($request->body(), 'prompt') - && str_contains($request->body(), 'Laravel Forge and Vapor'); - }); + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.openai.com/v1/audio/transcriptions' + && str_contains($request->body(), 'prompt') + && str_contains($request->body(), 'Laravel Forge and Vapor')); }); -test('transcription throws when prompt provider option is used with diarized models', function () { +test('transcription throws when prompt provider option is used with diarized models', function (): void { Http::fake(['*' => fakeOpenAiTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') @@ -37,42 +35,36 @@ ->generate(provider: 'openai', model: 'gpt-4o-transcribe-diarize'); })->throws(LogicException::class, 'OpenAI does not support the `prompt` option for diarized transcriptions.'); -test('transcription request posts to correct endpoint', function () { +test('transcription request posts to correct endpoint', function (): void { Http::fake(['*' => fakeOpenAiTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->generate(provider: 'openai'); - Http::assertSent(function (Request $request) { - return $request->url() === 'https://api.openai.com/v1/audio/transcriptions' - && str_contains($request->header('Content-Type')[0] ?? '', 'multipart/form-data'); - }); + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://api.openai.com/v1/audio/transcriptions' + && str_contains($request->header('Content-Type')[0] ?? '', 'multipart/form-data')); }); -test('transcription includes model in request', function () { +test('transcription includes model in request', function (): void { Http::fake(['*' => fakeOpenAiTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->generate(provider: 'openai'); - Http::assertSent(function (Request $request) { - return str_contains($request->body(), 'gpt-4o-transcribe'); - }); + Http::assertSent(fn (Request $request): bool => str_contains($request->body(), 'gpt-4o-transcribe')); }); -test('transcription strips diarize suffix from model when diarize is off', function () { +test('transcription strips diarize suffix from model when diarize is off', function (): void { Http::fake(['*' => fakeOpenAiTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->generate(provider: 'openai', model: 'gpt-4o-transcribe-diarize'); - Http::assertSent(function (Request $request) { - return str_contains($request->body(), 'gpt-4o-transcribe') - && ! str_contains($request->body(), 'gpt-4o-transcribe-diarize'); - }); + Http::assertSent(fn (Request $request): bool => str_contains($request->body(), 'gpt-4o-transcribe') + && ! str_contains($request->body(), 'gpt-4o-transcribe-diarize')); }); -test('transcription response text is correctly parsed', function () { +test('transcription response text is correctly parsed', function (): void { Http::fake(['*' => fakeOpenAiTranscriptionResponse('Hello, world!')]); $response = Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') @@ -82,7 +74,7 @@ ->and($response->meta->provider)->toBe('openai'); }); -test('transcription usage is correctly parsed', function () { +test('transcription usage is correctly parsed', function (): void { Http::fake(['*' => Http::response([ 'text' => 'Hello', 'usage' => [ @@ -99,31 +91,27 @@ ->and($response->usage->completionTokens)->toBe(50); }); -test('transcription sends language when provided', function () { +test('transcription sends language when provided', function (): void { Http::fake(['*' => fakeOpenAiTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->language('en') ->generate(provider: 'openai'); - Http::assertSent(function (Request $request) { - return str_contains($request->body(), 'language') - && str_contains($request->body(), 'en'); - }); + Http::assertSent(fn (Request $request): bool => str_contains($request->body(), 'language') + && str_contains($request->body(), 'en')); }); -test('transcription request sends bearer token', function () { +test('transcription request sends bearer token', function (): void { Http::fake(['*' => fakeOpenAiTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->generate(provider: 'openai'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('transcription rate limit response throws rate limited exception', function () { +test('transcription rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ @@ -137,7 +125,7 @@ ->generate(provider: 'openai', model: 'gpt-4o-transcribe'); })->throws(RateLimitedException::class); -test('transcription overloaded response throws provider overloaded exception', function () { +test('transcription overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ @@ -151,7 +139,7 @@ ->generate(provider: 'openai', model: 'gpt-4o-transcribe'); })->throws(ProviderOverloadedException::class); -test('transcription http error response throws request exception', function () { +test('transcription http error response throws request exception', function (): void { Http::fake([ 'api.openai.com/*' => Http::response([ 'error' => [ diff --git a/tests/Feature/Providers/OpenAiCompatible/OpenAiCompatibleTest.php b/tests/Feature/Providers/OpenAiCompatible/OpenAiCompatibleTest.php index 1c1318135..c67a42a56 100644 --- a/tests/Feature/Providers/OpenAiCompatible/OpenAiCompatibleTest.php +++ b/tests/Feature/Providers/OpenAiCompatible/OpenAiCompatibleTest.php @@ -6,6 +6,7 @@ use Laravel\Ai\Contracts\HasProviderOptions; use Laravel\Ai\Enums\Lab; use Laravel\Ai\Promptable; +use Laravel\Ai\Responses\AgentResponse; use Tests\Fixtures\Agents\AttributeAgent; use Tests\Fixtures\Agents\AttributeToolChoiceAgent; use Tests\Fixtures\Agents\StructuredAgent; @@ -13,11 +14,11 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { configureOpenAiCompatible(); }); -test('throws when no url is configured', function () { +test('throws when no url is configured', function (): void { config(['ai.providers.openai-compatible' => [ 'driver' => 'openai-compatible', 'key' => 'test-key', @@ -26,22 +27,22 @@ Http::fake(['*' => fakeOpenAiCompatibleResponse('Hello')]); - expect(fn () => agent()->prompt('Hello', provider: 'openai-compatible')) + expect(fn (): AgentResponse => agent()->prompt('Hello', provider: 'openai-compatible')) ->toThrow(InvalidArgumentException::class, "requires a 'url'"); }); -test('throws when no default model is configured and none is passed', function () { +test('throws when no default model is configured and none is passed', function (): void { config(['ai.providers.openai-compatible' => [ 'driver' => 'openai-compatible', 'url' => 'http://localhost:1234/v1', 'key' => 'test-key', ]]); - expect(fn () => agent()->prompt('Hello', provider: 'openai-compatible')) + expect(fn (): AgentResponse => agent()->prompt('Hello', provider: 'openai-compatible')) ->toThrow(InvalidArgumentException::class, 'requires a default text model'); }); -test('text requests use the configured base url and path', function () { +test('text requests use the configured base url and path', function (): void { Http::fake(['*' => fakeOpenAiCompatibleResponse('Hello from local model')]); $response = agent()->prompt('Hello', provider: 'openai-compatible'); @@ -49,11 +50,11 @@ expect($response->text)->toBe('Hello from local model') ->and($response->meta->provider)->toBe('openai-compatible'); - Http::assertSent(fn (Request $request) => $request->method() === 'POST' + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->url() === 'http://localhost:1234/v1/chat/completions'); }); -test('request sends bearer token authorization', function () { +test('request sends bearer token authorization', function (): void { Http::fake(['*' => fakeOpenAiCompatibleResponse('Hello')]); agent()->prompt('Hello', provider: 'openai-compatible'); @@ -61,7 +62,7 @@ Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('request omits authorization header when no key is configured', function () { +test('request omits authorization header when no key is configured', function (): void { config(['ai.providers.openai-compatible' => [ ...config('ai.providers.openai-compatible'), 'key' => null, @@ -71,10 +72,10 @@ agent()->prompt('Hello', provider: 'openai-compatible'); - Http::assertSent(fn (Request $request) => ! $request->hasHeader('Authorization')); + Http::assertSent(fn (Request $request): bool => ! $request->hasHeader('Authorization')); }); -test('request works when the key is absent from the config entirely', function () { +test('request works when the key is absent from the config entirely', function (): void { config(['ai.providers.openai-compatible' => [ 'driver' => 'openai-compatible', 'url' => 'http://localhost:1234/v1', @@ -85,15 +86,15 @@ agent()->prompt('Hello', provider: 'openai-compatible'); - Http::assertSent(fn (Request $request) => ! $request->hasHeader('Authorization')); + Http::assertSent(fn (Request $request): bool => ! $request->hasHeader('Authorization')); }); -test('structured output defaults to json schema response format', function () { +test('structured output defaults to json schema response format', function (): void { Http::fake(['*' => fakeOpenAiCompatibleResponse('{"symbol": "Au"}')]); (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'openai-compatible'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $format = data_get(json_decode($request->body(), true), 'response_format'); return $format['type'] === 'json_schema' @@ -101,49 +102,47 @@ }); }); -test('required tool choice forces the model to call a tool', function () { +test('required tool choice forces the model to call a tool', function (): void { Http::fake(['*' => fakeOpenAiCompatibleResponse('42')]); (new ToolChoiceAgent('required'))->prompt('Give me a number', provider: 'openai-compatible'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'required'); }); -test('required tool choice can be set via attribute', function () { +test('required tool choice can be set via attribute', function (): void { Http::fake(['*' => fakeOpenAiCompatibleResponse('42')]); (new AttributeToolChoiceAgent)->prompt('Give me a number', provider: 'openai-compatible'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'required'); }); -test('named tool choice forces a specific function', function () { +test('named tool choice forces a specific function', function (): void { Http::fake(['*' => fakeOpenAiCompatibleResponse('42')]); (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Give me a number', provider: 'openai-compatible'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['tool_choice'] === [ - 'type' => 'function', - 'function' => ['name' => 'custom_named_tool'], - ]; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === [ + 'type' => 'function', + 'function' => ['name' => 'custom_named_tool'], + ]); }); -test('none tool choice prevents tool calls', function () { +test('none tool choice prevents tool calls', function (): void { Http::fake(['*' => fakeOpenAiCompatibleResponse('Sure')]); (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'openai-compatible'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'none'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'none'); }); -test('max tokens uses the max_tokens field by default', function () { +test('max tokens uses the max_tokens field by default', function (): void { Http::fake(['*' => fakeOpenAiCompatibleResponse('Hello')]); (new AttributeAgent)->prompt('Hello', provider: 'openai-compatible'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'max_tokens') === 4096 @@ -151,7 +150,7 @@ }); }); -test('response usage is parsed using the openai standard shape', function () { +test('response usage is parsed using the openai standard shape', function (): void { Http::fake(['*' => Http::response([ 'id' => 'chatcmpl-1', 'object' => 'chat.completion', @@ -177,14 +176,14 @@ ->and($response->usage->reasoningTokens)->toBe(10); }); -test('streaming omits stream_options by default', function () { +test('streaming omits stream_options by default', function (): void { Http::fake(['*' => fakeOpenAiCompatibleStream()]); foreach (agent()->stream('Hello', provider: 'openai-compatible') as $event) { // drain the stream } - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['stream'] === true @@ -192,7 +191,7 @@ }); }); -test('streaming sends stream_options when configured on the instance', function () { +test('streaming sends stream_options when configured on the instance', function (): void { config(['ai.providers.openai-compatible' => [ ...config('ai.providers.openai-compatible'), 'stream_options' => ['include_usage' => true], @@ -204,12 +203,10 @@ // drain the stream } - Http::assertSent(function (Request $request) { - return data_get(json_decode($request->body(), true), 'stream_options.include_usage') === true; - }); + Http::assertSent(fn (Request $request): bool => data_get(json_decode($request->body(), true), 'stream_options.include_usage') === true); }); -test('streaming sends stream_options supplied via provider options', function () { +test('streaming sends stream_options supplied via provider options', function (): void { Http::fake(['*' => fakeOpenAiCompatibleStream()]); $agent = new class implements Agent, HasProviderOptions @@ -231,12 +228,10 @@ public function providerOptions(Lab|string $provider): array // drain the stream } - Http::assertSent(function (Request $request) { - return data_get(json_decode($request->body(), true), 'stream_options.include_usage') === true; - }); + Http::assertSent(fn (Request $request): bool => data_get(json_decode($request->body(), true), 'stream_options.include_usage') === true); }); -test('custom named instances use their own configured base url and model', function () { +test('custom named instances use their own configured base url and model', function (): void { config(['ai.providers.lm-studio' => [ 'driver' => 'openai-compatible', 'url' => 'http://localhost:4321/v1', @@ -251,12 +246,12 @@ public function providerOptions(Lab|string $provider): array expect($response->text)->toBe('Hello from LM Studio') ->and($response->meta->provider)->toBe('lm-studio'); - Http::assertSent(fn (Request $request) => $request->url() === 'http://localhost:4321/v1/chat/completions' + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://localhost:4321/v1/chat/completions' && $request->hasHeader('Authorization', 'Bearer lm-studio-key') && data_get(json_decode($request->body(), true), 'model') === 'lm-studio-model'); }); -test('named instances resolve provider options by their instance name', function () { +test('named instances resolve provider options by their instance name', function (): void { config(['ai.providers.lm-studio' => [ 'driver' => 'openai-compatible', 'url' => 'http://localhost:4321/v1', @@ -295,10 +290,10 @@ public function providerOptions(Lab|string $provider): array $agent->prompt('Hello', provider: 'lm-studio'); $agent->prompt('Hello', provider: 'vllm'); - Http::assertSent(fn (Request $request) => $request->url() === 'http://localhost:4321/v1/chat/completions' + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://localhost:4321/v1/chat/completions' && data_get(json_decode($request->body(), true), 'top_k') === 40); - Http::assertSent(fn (Request $request) => $request->url() === 'http://localhost:8000/v1/chat/completions' + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://localhost:8000/v1/chat/completions' && data_get(json_decode($request->body(), true), 'top_k') === 10); }); @@ -329,7 +324,7 @@ function fakeOpenAiCompatibleResponse(string $content) function fakeOpenAiCompatibleStream() { - $chunk = fn (array $delta, ?string $finish = null) => 'data: '.json_encode([ + $chunk = fn (array $delta, ?string $finish = null): string => 'data: '.json_encode([ 'id' => 'chatcmpl-123', 'object' => 'chat.completion.chunk', 'model' => 'local-model', diff --git a/tests/Feature/Providers/OpenRouter/AgentFakeTest.php b/tests/Feature/Providers/OpenRouter/AgentFakeTest.php index bd19604ab..eede30c89 100644 --- a/tests/Feature/Providers/OpenRouter/AgentFakeTest.php +++ b/tests/Feature/Providers/OpenRouter/AgentFakeTest.php @@ -2,7 +2,7 @@ use Tests\Fixtures\Agents\OpenRouterAgent; -test('openrouter agent can be faked', function () { +test('openrouter agent can be faked', function (): void { OpenRouterAgent::fake(['Test response']); $response = (new OpenRouterAgent)->prompt('Hello'); @@ -10,15 +10,15 @@ expect($response->text)->toBe('Test response'); }); -test('openrouter agent fake with closure', function () { - OpenRouterAgent::fake(fn (string $prompt) => "Echo: {$prompt}"); +test('openrouter agent fake with closure', function (): void { + OpenRouterAgent::fake(fn (string $prompt): string => "Echo: {$prompt}"); $response = (new OpenRouterAgent)->prompt('Hello world'); expect($response->text)->toBe('Echo: Hello world'); }); -test('openrouter agent fake with no predefined responses', function () { +test('openrouter agent fake with no predefined responses', function (): void { OpenRouterAgent::fake(); $response = (new OpenRouterAgent)->prompt('Hello'); @@ -26,7 +26,7 @@ expect($response->text)->toBe('Fake response for prompt: Hello'); }); -test('openrouter agent fake records prompts', function () { +test('openrouter agent fake records prompts', function (): void { OpenRouterAgent::fake(); (new OpenRouterAgent)->prompt('Hello'); @@ -35,11 +35,11 @@ OpenRouterAgent::assertNotPrompted('Goodbye'); }); -test('openrouter agent stream can be faked', function () { +test('openrouter agent stream can be faked', function (): void { OpenRouterAgent::fake(['Streamed response']); $response = (new OpenRouterAgent)->stream('Hello'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->text)->toBe('Streamed response'); }); diff --git a/tests/Feature/Providers/OpenRouter/AudioTest.php b/tests/Feature/Providers/OpenRouter/AudioTest.php index c2b6f24a1..3d1241485 100644 --- a/tests/Feature/Providers/OpenRouter/AudioTest.php +++ b/tests/Feature/Providers/OpenRouter/AudioTest.php @@ -8,7 +8,7 @@ use Laravel\Ai\Exceptions\ProviderOverloadedException; use Laravel\Ai\Exceptions\RateLimitedException; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openrouter' => [ ...config('ai.providers.openrouter'), 'key' => 'test-key', @@ -20,12 +20,12 @@ function fakeOpenRouterAudioResponse(): PromiseInterface return Http::response('fake-audio-bytes', 200, ['Content-Type' => 'audio/mpeg']); } -test('audio request includes model, input, voice, response format, and speed', function () { +test('audio request includes model, input, voice, response format, and speed', function (): void { Http::fake(['*' => fakeOpenRouterAudioResponse()]); Audio::of('Hello world')->generate(provider: 'openrouter', model: 'openai/gpt-4o-mini-tts-2025-12-15'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'openai/gpt-4o-mini-tts-2025-12-15' @@ -37,99 +37,81 @@ function fakeOpenRouterAudioResponse(): PromiseInterface }); }); -test('audio request resolves default-female voice to alloy', function () { +test('audio request resolves default-female voice to alloy', function (): void { Http::fake(['*' => fakeOpenRouterAudioResponse()]); Audio::of('Hello')->female()->generate(provider: 'openrouter', model: 'openai/gpt-4o-mini-tts-2025-12-15'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['voice'] === 'alloy'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['voice'] === 'alloy'); }); -test('audio request resolves default-male voice to ash', function () { +test('audio request resolves default-male voice to ash', function (): void { Http::fake(['*' => fakeOpenRouterAudioResponse()]); Audio::of('Hello')->male()->generate(provider: 'openrouter', model: 'openai/gpt-4o-mini-tts-2025-12-15'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['voice'] === 'ash'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['voice'] === 'ash'); }); -test('audio request passes custom voice id through unchanged', function () { +test('audio request passes custom voice id through unchanged', function (): void { Http::fake(['*' => fakeOpenRouterAudioResponse()]); Audio::of('Hello')->voice('shimmer')->generate(provider: 'openrouter', model: 'openai/gpt-4o-mini-tts-2025-12-15'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['voice'] === 'shimmer'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['voice'] === 'shimmer'); }); -test('audio request includes instructions when provided', function () { +test('audio request includes instructions when provided', function (): void { Http::fake(['*' => fakeOpenRouterAudioResponse()]); Audio::of('Hello')->instructions('Speak slowly')->generate(provider: 'openrouter', model: 'openai/gpt-4o-mini-tts-2025-12-15'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['instructions'] === 'Speak slowly'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['instructions'] === 'Speak slowly'); }); -test('audio request omits instructions when not provided', function () { +test('audio request omits instructions when not provided', function (): void { Http::fake(['*' => fakeOpenRouterAudioResponse()]); Audio::of('Hello')->generate(provider: 'openrouter', model: 'openai/gpt-4o-mini-tts-2025-12-15'); - Http::assertSent(function (Request $request) { - return ! array_key_exists('instructions', json_decode($request->body(), true)); - }); + Http::assertSent(fn (Request $request): bool => ! array_key_exists('instructions', json_decode($request->body(), true))); }); -test('audio uses default model when none specified', function () { +test('audio uses default model when none specified', function (): void { Http::fake(['*' => fakeOpenRouterAudioResponse()]); Audio::of('Hello')->generate(provider: 'openrouter'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['model'] === 'google/gemini-3.1-flash-tts-preview'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['model'] === 'google/gemini-3.1-flash-tts-preview'); }); -test('audio request to gemini tts model uses pcm response format and pcm mime', function () { +test('audio request to gemini tts model uses pcm response format and pcm mime', function (): void { Http::fake(['*' => fakeOpenRouterAudioResponse()]); $response = Audio::of('Hello')->generate(provider: 'openrouter'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['response_format'] === 'pcm'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['response_format'] === 'pcm'); expect($response->mimeType())->toBe('audio/pcm'); }); -test('audio request to gemini tts model resolves default-female voice to Kore', function () { +test('audio request to gemini tts model resolves default-female voice to Kore', function (): void { Http::fake(['*' => fakeOpenRouterAudioResponse()]); Audio::of('Hello')->female()->generate(provider: 'openrouter'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['voice'] === 'Kore'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['voice'] === 'Kore'); }); -test('audio request to gemini tts model resolves default-male voice to Puck', function () { +test('audio request to gemini tts model resolves default-male voice to Puck', function (): void { Http::fake(['*' => fakeOpenRouterAudioResponse()]); Audio::of('Hello')->male()->generate(provider: 'openrouter'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['voice'] === 'Puck'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['voice'] === 'Puck'); }); -test('audio response is base64-encoded with audio/mpeg mime type', function () { +test('audio response is base64-encoded with audio/mpeg mime type', function (): void { Http::fake(['*' => fakeOpenRouterAudioResponse()]); $response = Audio::of('Hello')->generate(provider: 'openrouter', model: 'openai/gpt-4o-mini-tts-2025-12-15'); @@ -140,7 +122,7 @@ function fakeOpenRouterAudioResponse(): PromiseInterface ->and($response->meta->model)->toBe('openai/gpt-4o-mini-tts-2025-12-15'); }); -test('audio request sends bearer token', function () { +test('audio request sends bearer token', function (): void { Http::fake(['*' => fakeOpenRouterAudioResponse()]); Audio::of('Hello')->generate(provider: 'openrouter', model: 'openai/gpt-4o-mini-tts-2025-12-15'); @@ -148,7 +130,7 @@ function fakeOpenRouterAudioResponse(): PromiseInterface Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('audio request sends openrouter attribution headers when configured', function () { +test('audio request sends openrouter attribution headers when configured', function (): void { config(['ai.providers.openrouter' => [ ...config('ai.providers.openrouter'), 'key' => 'test-key', @@ -160,13 +142,11 @@ function fakeOpenRouterAudioResponse(): PromiseInterface Audio::of('Hello')->generate(provider: 'openrouter', model: 'openai/gpt-4o-mini-tts-2025-12-15'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('HTTP-Referer', 'https://example.test') - && $request->hasHeader('X-OpenRouter-Title', 'Example App'); - }); + Http::assertSent(fn (Request $request): bool => $request->hasHeader('HTTP-Referer', 'https://example.test') + && $request->hasHeader('X-OpenRouter-Title', 'Example App')); }); -test('audio rate limit response throws rate limited exception', function () { +test('audio rate limit response throws rate limited exception', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response([ 'error' => [ @@ -179,7 +159,7 @@ function fakeOpenRouterAudioResponse(): PromiseInterface Audio::of('Hello')->generate(provider: 'openrouter', model: 'openai/gpt-4o-mini-tts-2025-12-15'); })->throws(RateLimitedException::class); -test('audio overloaded response throws provider overloaded exception', function () { +test('audio overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response([ 'error' => [ @@ -192,7 +172,7 @@ function fakeOpenRouterAudioResponse(): PromiseInterface Audio::of('Hello')->generate(provider: 'openrouter', model: 'openai/gpt-4o-mini-tts-2025-12-15'); })->throws(ProviderOverloadedException::class); -test('audio http error response throws request exception', function () { +test('audio http error response throws request exception', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response([ 'error' => [ diff --git a/tests/Feature/Providers/OpenRouter/BaseUrlTest.php b/tests/Feature/Providers/OpenRouter/BaseUrlTest.php index 4a146957b..cd2f4b0cc 100644 --- a/tests/Feature/Providers/OpenRouter/BaseUrlTest.php +++ b/tests/Feature/Providers/OpenRouter/BaseUrlTest.php @@ -5,11 +5,11 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { $this->customUrl = 'http://localhost:1234/v1'; }); -test('openrouter text requests use the configured base url', function () { +test('openrouter text requests use the configured base url', function (): void { configureOpenRouterProvider($this->customUrl); Http::fake(['*' => fakeOpenRouterResponse('Hello from local model')]); @@ -22,7 +22,7 @@ openRouterAssertRequestSent('POST', "{$this->customUrl}/chat/completions"); }); -test('openrouter requests fall back to the default base url', function () { +test('openrouter requests fall back to the default base url', function (): void { configureOpenRouterProvider(); Http::fake(['*' => fakeOpenRouterResponse('Hello from OpenRouter')]); @@ -46,6 +46,6 @@ function configureOpenRouterProvider(?string $url = null): void function openRouterAssertRequestSent(string $method, string $url): void { - Http::assertSent(fn (Request $request) => $request->method() === $method + Http::assertSent(fn (Request $request): bool => $request->method() === $method && $request->url() === $url); } diff --git a/tests/Feature/Providers/OpenRouter/EmbeddingsTest.php b/tests/Feature/Providers/OpenRouter/EmbeddingsTest.php index 02cf0e522..71641a341 100644 --- a/tests/Feature/Providers/OpenRouter/EmbeddingsTest.php +++ b/tests/Feature/Providers/OpenRouter/EmbeddingsTest.php @@ -7,14 +7,14 @@ use Laravel\Ai\Exceptions\ProviderOverloadedException; use Laravel\Ai\Exceptions\RateLimitedException; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openrouter' => [ ...config('ai.providers.openrouter'), 'key' => 'test-key', ]]); }); -test('embeddings request is correctly formatted', function () { +test('embeddings request is correctly formatted', function (): void { Http::fake(['*' => Http::response([ 'object' => 'list', 'data' => [ @@ -25,7 +25,7 @@ Ai::instance('openrouter')->embeddings(['Hello world']); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'google/gemini-embedding-001' @@ -35,7 +35,7 @@ }); }); -test('embeddings response is correctly parsed', function () { +test('embeddings response is correctly parsed', function (): void { Http::fake(['*' => Http::response([ 'object' => 'list', 'data' => [ @@ -53,7 +53,7 @@ ->and($response->tokens)->toBe(10); }); -test('embeddings request sends bearer token', function () { +test('embeddings request sends bearer token', function (): void { Http::fake(['*' => Http::response([ 'object' => 'list', 'data' => [['object' => 'embedding', 'index' => 0, 'embedding' => [0.1]]], @@ -65,7 +65,7 @@ Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('embeddings request uses openrouter base url', function () { +test('embeddings request uses openrouter base url', function (): void { Http::fake(['*' => Http::response([ 'object' => 'list', 'data' => [['object' => 'embedding', 'index' => 0, 'embedding' => [0.1]]], @@ -74,10 +74,10 @@ Ai::instance('openrouter')->embeddings(['test']); - Http::assertSent(fn (Request $request) => $request->url() === 'https://openrouter.ai/api/v1/embeddings'); + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://openrouter.ai/api/v1/embeddings'); }); -test('embeddings request includes provider options in the request body', function () { +test('embeddings request includes provider options in the request body', function (): void { Http::fake(['*' => Http::response([ 'object' => 'list', 'data' => [['object' => 'embedding', 'index' => 0, 'embedding' => [0.1]]], @@ -89,7 +89,7 @@ providerOptions: ['encoding_format' => 'base64'], ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['encoding_format'] === 'base64' @@ -97,7 +97,7 @@ }); }); -test('embeddings rate limit response throws rate limited exception', function () { +test('embeddings rate limit response throws rate limited exception', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response(['error' => ['message' => 'Rate limited']], 429), ]); @@ -106,7 +106,7 @@ ->toThrow(RateLimitedException::class); }); -test('embeddings overloaded response throws provider overloaded exception', function () { +test('embeddings overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response(['error' => ['message' => 'Server overloaded']], 503), ]); @@ -115,7 +115,7 @@ ->toThrow(ProviderOverloadedException::class); }); -test('embeddings error in 200 response throws ai exception', function () { +test('embeddings error in 200 response throws ai exception', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response([ 'error' => [ diff --git a/tests/Feature/Providers/OpenRouter/ErrorHandlingTest.php b/tests/Feature/Providers/OpenRouter/ErrorHandlingTest.php index cb197fa81..f032b86b9 100644 --- a/tests/Feature/Providers/OpenRouter/ErrorHandlingTest.php +++ b/tests/Feature/Providers/OpenRouter/ErrorHandlingTest.php @@ -8,26 +8,26 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Tests\Fixtures\Agents\AssistantAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openrouter' => [ ...config('ai.providers.openrouter'), 'key' => 'test-key', ]]); }); -test('http error response throws request exception', function () { +test('http error response throws request exception', function (): void { Http::fake(['openrouter.ai/*' => Http::response(['error' => ['message' => 'Bad request']], 400)]); (new AssistantAgent)->prompt('Hello', provider: 'openrouter'); })->throws(RequestException::class); -test('rate limit response throws rate limited exception', function () { +test('rate limit response throws rate limited exception', function (): void { Http::fake(['openrouter.ai/*' => Http::response(['error' => ['message' => 'Rate limited']], 429)]); (new AssistantAgent)->prompt('Hello', provider: 'openrouter'); })->throws(RateLimitedException::class); -test('overloaded response throws provider overloaded exception', function () { +test('overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response([ 'error' => [ @@ -40,7 +40,7 @@ (new AssistantAgent)->prompt('Hello', provider: 'openrouter'); })->throws(ProviderOverloadedException::class); -test('402 response throws insufficient credits exception', function () { +test('402 response throws insufficient credits exception', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response([ 'error' => [ @@ -53,7 +53,7 @@ (new AssistantAgent)->prompt('Hello', provider: 'openrouter'); })->throws(InsufficientCreditsException::class); -test('error in 200 response throws ai exception', function () { +test('error in 200 response throws ai exception', function (): void { Http::fake(['openrouter.ai/*' => Http::response([ 'error' => [ 'type' => 'invalid_request_error', diff --git a/tests/Feature/Providers/OpenRouter/ImageGenerationTest.php b/tests/Feature/Providers/OpenRouter/ImageGenerationTest.php index dd1d89820..dae09378e 100644 --- a/tests/Feature/Providers/OpenRouter/ImageGenerationTest.php +++ b/tests/Feature/Providers/OpenRouter/ImageGenerationTest.php @@ -10,7 +10,7 @@ use Laravel\Ai\Files\RemoteImage; use Laravel\Ai\Image; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openrouter' => [ ...config('ai.providers.openrouter'), 'key' => 'test-key', @@ -37,12 +37,12 @@ function fakeOpenRouterImageResponse(string $mimeType = 'image/png', string $con ]); } -test('image request includes model, messages, and modalities', function () { +test('image request includes model, messages, and modalities', function (): void { Http::fake(['openrouter.ai/*' => fakeOpenRouterImageResponse()]); Image::of('A blue circle')->generate(provider: 'openrouter', model: 'google/gemini-2.5-flash-image'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return str_contains($request->url(), 'chat/completions') @@ -53,12 +53,12 @@ function fakeOpenRouterImageResponse(string $mimeType = 'image/png', string $con }); }); -test('image request maps size to image_config aspect_ratio', function () { +test('image request maps size to image_config aspect_ratio', function (): void { Http::fake(['openrouter.ai/*' => fakeOpenRouterImageResponse()]); Image::of('A sunset')->landscape()->generate(provider: 'openrouter', model: 'google/gemini-2.5-flash-image'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'image_config.aspect_ratio') === '3:2' @@ -66,12 +66,12 @@ function fakeOpenRouterImageResponse(string $mimeType = 'image/png', string $con }); }); -test('image request maps quality to image_config image_size', function (string $quality, string $expectedSize) { +test('image request maps quality to image_config image_size', function (string $quality, string $expectedSize): void { Http::fake(['openrouter.ai/*' => fakeOpenRouterImageResponse()]); Image::of('A sunset')->quality($quality)->generate(provider: 'openrouter', model: 'google/gemini-2.5-flash-image'); - Http::assertSent(function (Request $request) use ($expectedSize) { + Http::assertSent(function (Request $request) use ($expectedSize): bool { $body = json_decode($request->body(), true); return data_get($body, 'image_config.image_size') === $expectedSize; @@ -82,19 +82,19 @@ function fakeOpenRouterImageResponse(string $mimeType = 'image/png', string $con 'high maps to 4K' => ['high', '4K'], ]); -test('image request omits image_config when no size or quality is given', function () { +test('image request omits image_config when no size or quality is given', function (): void { Http::fake(['openrouter.ai/*' => fakeOpenRouterImageResponse()]); Image::of('A sunset')->generate(provider: 'openrouter', model: 'google/gemini-2.5-flash-image'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('image_config', $body); }); }); -test('image response is correctly parsed', function () { +test('image response is correctly parsed', function (): void { Http::fake(['openrouter.ai/*' => fakeOpenRouterImageResponse('image/png', 'fake-image')]); $response = Image::of('A blue circle')->generate(provider: 'openrouter', model: 'google/gemini-2.5-flash-image'); @@ -106,7 +106,7 @@ function fakeOpenRouterImageResponse(string $mimeType = 'image/png', string $con ->and($response->meta->model)->toBe('google/gemini-2.5-flash-image'); }); -test('usage tokens are parsed from response', function () { +test('usage tokens are parsed from response', function (): void { Http::fake(['openrouter.ai/*' => fakeOpenRouterImageResponse()]); $response = Image::of('A blue circle')->generate(provider: 'openrouter', model: 'google/gemini-2.5-flash-image'); @@ -115,7 +115,7 @@ function fakeOpenRouterImageResponse(string $mimeType = 'image/png', string $con ->and($response->usage->completionTokens)->toBe(20); }); -test('multiple images in response are all returned', function () { +test('multiple images in response are all returned', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response([ 'choices' => [[ @@ -138,7 +138,7 @@ function fakeOpenRouterImageResponse(string $mimeType = 'image/png', string $con ->and($response->images[1]->mime)->toBe('image/jpeg'); }); -test('empty images collection returned when response contains no images', function () { +test('empty images collection returned when response contains no images', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response([ 'choices' => [[ @@ -152,14 +152,14 @@ function fakeOpenRouterImageResponse(string $mimeType = 'image/png', string $con expect($response->images)->toHaveCount(0); }); -test('attachments are sent as image_url content parts', function () { +test('attachments are sent as image_url content parts', function (): void { Http::fake(['openrouter.ai/*' => fakeOpenRouterImageResponse()]); $attachment = new Base64Image(base64_encode('source-image'), 'image/jpeg'); Image::of('Edit this image')->attachments([$attachment])->generate(provider: 'openrouter', model: 'google/gemini-2.5-flash-image'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $content = $body['messages'][0]['content']; @@ -167,18 +167,18 @@ function fakeOpenRouterImageResponse(string $mimeType = 'image/png', string $con && $content[0]['type'] === 'text' && $content[0]['text'] === 'Edit this image' && $content[1]['type'] === 'image_url' - && str_starts_with($content[1]['image_url']['url'], 'data:image/jpeg;base64,'); + && str_starts_with((string) $content[1]['image_url']['url'], 'data:image/jpeg;base64,'); }); }); -test('remote image attachments are passed as url without downloading', function () { +test('remote image attachments are passed as url without downloading', function (): void { Http::fake(['openrouter.ai/*' => fakeOpenRouterImageResponse()]); $attachment = new RemoteImage('https://example.com/photo.jpg', 'image/jpeg'); Image::of('Edit this image')->attachments([$attachment])->generate(provider: 'openrouter', model: 'google/gemini-2.5-flash-image'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $content = $body['messages'][0]['content']; @@ -188,17 +188,15 @@ function fakeOpenRouterImageResponse(string $mimeType = 'image/png', string $con }); }); -test('request sends bearer token authorization', function () { +test('request sends bearer token authorization', function (): void { Http::fake(['openrouter.ai/*' => fakeOpenRouterImageResponse()]); Image::of('A blue circle')->generate(provider: 'openrouter', model: 'google/gemini-2.5-flash-image'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('image rate limit response throws rate limited exception', function () { +test('image rate limit response throws rate limited exception', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response([ 'error' => [ @@ -211,7 +209,7 @@ function fakeOpenRouterImageResponse(string $mimeType = 'image/png', string $con Image::of('A blue circle')->generate(provider: 'openrouter', model: 'google/gemini-2.5-flash-image'); })->throws(RateLimitedException::class); -test('image overloaded response throws provider overloaded exception', function () { +test('image overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response([ 'error' => [ @@ -224,7 +222,7 @@ function fakeOpenRouterImageResponse(string $mimeType = 'image/png', string $con Image::of('A blue circle')->generate(provider: 'openrouter', model: 'google/gemini-2.5-flash-image'); })->throws(ProviderOverloadedException::class); -test('image http error response throws request exception', function () { +test('image http error response throws request exception', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response([ 'error' => [ diff --git a/tests/Feature/Providers/OpenRouter/MessageMappingTest.php b/tests/Feature/Providers/OpenRouter/MessageMappingTest.php index f80abd037..ddf796f13 100644 --- a/tests/Feature/Providers/OpenRouter/MessageMappingTest.php +++ b/tests/Feature/Providers/OpenRouter/MessageMappingTest.php @@ -8,19 +8,19 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openrouter' => [ ...config('ai.providers.openrouter'), 'key' => 'test-key', ]]); }); -test('user message maps to chat completions format', function () { +test('user message maps to chat completions format', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Hello')]); agent()->prompt('Hello there', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMsg = collect($body['messages'])->firstWhere('role', 'user'); @@ -29,20 +29,20 @@ }); }); -test('system instructions are sent as system role message', function () { +test('system instructions are sent as system role message', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Hello')]); (new AssistantAgent)->prompt('Hello', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['messages'][0]['role'] === 'system' - && str_contains($body['messages'][0]['content'], 'helpful assistant'); + && str_contains((string) $body['messages'][0]['content'], 'helpful assistant'); }); }); -test('tool result follow up maps assistant and tool result messages', function () { +test('tool result follow up maps assistant and tool result messages', function (): void { Http::fake([ '*' => Http::sequence([ fakeOpenRouterToolCallResponse(), @@ -52,8 +52,8 @@ agent(tools: [new FixedNumberGenerator])->prompt('Give me a number', provider: 'openrouter'); - $requests = Http::recorded(fn (Request $r) => true); - $followUpBody = json_decode($requests[1][0]->body(), true); + $requests = Http::recorded(fn (Request $r): true => true); + $followUpBody = json_decode((string) $requests[1][0]->body(), true); $messages = $followUpBody['messages']; $assistantMsg = collect($messages)->firstWhere('role', 'assistant'); @@ -66,7 +66,7 @@ ->and($toolMsg['tool_call_id'])->toBe('call_123'); }); -test('local image attachment without explicit mime type detects mime from file', function () { +test('local image attachment without explicit mime type detects mime from file', function (): void { Http::fake(['*' => fakeOpenRouterResponse('I see an image')]); agent('You are helpful.')->prompt( @@ -75,13 +75,13 @@ provider: 'openrouter', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMsg = collect($body['messages'])->firstWhere('role', 'user'); $imageBlock = collect($userMsg['content'])->firstWhere('type', 'image_url'); return $imageBlock !== null - && str_starts_with($imageBlock['image_url']['url'], 'data:image/png;base64,') - && ! str_contains($imageBlock['image_url']['url'], 'data:;base64,'); + && str_starts_with((string) $imageBlock['image_url']['url'], 'data:image/png;base64,') + && ! str_contains((string) $imageBlock['image_url']['url'], 'data:;base64,'); }); }); diff --git a/tests/Feature/Providers/OpenRouter/ProviderOptionsTest.php b/tests/Feature/Providers/OpenRouter/ProviderOptionsTest.php index 6d345a4b7..d9b2c1714 100644 --- a/tests/Feature/Providers/OpenRouter/ProviderOptionsTest.php +++ b/tests/Feature/Providers/OpenRouter/ProviderOptionsTest.php @@ -11,19 +11,19 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openrouter' => [ ...config('ai.providers.openrouter'), 'key' => 'test-key', ]]); }); -test('provider options are included in openrouter request body', function () { +test('provider options are included in openrouter request body', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Hello')]); (new ProviderOptionsAgent)->prompt('Hello', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'frequency_penalty') === 0.5 @@ -31,12 +31,12 @@ }); }); -test('request body does not contain provider options when agent does not implement interface', function () { +test('request body does not contain provider options when agent does not implement interface', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Hello')]); agent()->prompt('Hello', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('frequency_penalty', $body) @@ -44,7 +44,7 @@ }); }); -test('agents using the docs idiom (no Lab::tryFrom workaround) have provider options reach the openrouter body', function () { +test('agents using the docs idiom (no Lab::tryFrom workaround) have provider options reach the openrouter body', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Hello')]); $agent = new class implements Agent, HasProviderOptions @@ -67,14 +67,14 @@ public function providerOptions(Lab|string $provider): array $agent->prompt('Hello', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'reasoning.effort') === 'medium'; }); }); -test('provider options are persisted in tool call follow up requests', function () { +test('provider options are persisted in tool call follow up requests', function (): void { Http::fake([ '*' => Http::sequence([ fakeOpenRouterToolCallResponse(), @@ -84,11 +84,11 @@ public function providerOptions(Lab|string $provider): array (new ProviderOptionsWithToolsAgent)->prompt('Give me a number', provider: 'openrouter'); - $requests = Http::recorded(fn (Request $r) => true); + $requests = Http::recorded(fn (Request $r): true => true); expect(count($requests))->toBeGreaterThanOrEqual(2); - $followUpBody = json_decode($requests[1][0]->body(), true); + $followUpBody = json_decode((string) $requests[1][0]->body(), true); expect(data_get($followUpBody, 'frequency_penalty'))->toBe(0.5); }); diff --git a/tests/Feature/Providers/OpenRouter/RequestMappingTest.php b/tests/Feature/Providers/OpenRouter/RequestMappingTest.php index aa44536be..c785f4f4d 100644 --- a/tests/Feature/Providers/OpenRouter/RequestMappingTest.php +++ b/tests/Feature/Providers/OpenRouter/RequestMappingTest.php @@ -11,47 +11,47 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openrouter' => [ ...config('ai.providers.openrouter'), 'key' => 'test-key', ]]); }); -test('request includes model and messages', function () { +test('request includes model and messages', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Hello')]); agent()->prompt('Hi there', provider: 'openrouter', model: 'anthropic/claude-sonnet-4.6'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'anthropic/claude-sonnet-4.6' && count($body['messages']) >= 1 - && collect($body['messages'])->contains(fn ($m) => $m['role'] === 'user' && $m['content'] === 'Hi there'); + && collect($body['messages'])->contains(fn ($m): bool => $m['role'] === 'user' && $m['content'] === 'Hi there'); }); }); -test('system instructions are sent as system message', function () { +test('system instructions are sent as system message', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Hello')]); (new AssistantAgent)->prompt('Hello', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $systemMsg = collect($body['messages'])->firstWhere('role', 'system'); return $systemMsg !== null - && str_contains($systemMsg['content'], 'helpful assistant'); + && str_contains((string) $systemMsg['content'], 'helpful assistant'); }); }); -test('temperature and max tokens are included when set via attributes', function () { +test('temperature and max tokens are included when set via attributes', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Hello')]); (new AttributeAgent)->prompt('Hello', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'temperature') === 0.7 @@ -59,12 +59,12 @@ }); }); -test('temperature and max tokens are excluded when not set', function () { +test('temperature and max tokens are excluded when not set', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Hello')]); agent()->prompt('Hello', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('temperature', $body) @@ -72,26 +72,26 @@ }); }); -test('tools include tool choice auto', function () { +test('tools include tool choice auto', function (): void { Http::fake(['*' => fakeOpenRouterResponse('42')]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a number', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['tool_choice'] === 'auto' && is_array($body['tools']) - && count($body['tools']) > 0; + && $body['tools'] !== []; }); }); -test('request without tools excludes tool fields', function () { +test('request without tools excludes tool fields', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Hello')]); agent()->prompt('Hello', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('tools', $body) @@ -99,49 +99,47 @@ }); }); -test('required tool choice forces the model to call a tool', function () { +test('required tool choice forces the model to call a tool', function (): void { Http::fake(['*' => fakeOpenRouterResponse('42')]); (new ToolChoiceAgent('required'))->prompt('Give me a number', provider: 'openrouter'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'required'); }); -test('required tool choice can be set via attribute', function () { +test('required tool choice can be set via attribute', function (): void { Http::fake(['*' => fakeOpenRouterResponse('42')]); (new AttributeToolChoiceAgent)->prompt('Give me a number', provider: 'openrouter'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'required'); }); -test('named tool choice forces a specific function', function () { +test('named tool choice forces a specific function', function (): void { Http::fake(['*' => fakeOpenRouterResponse('42')]); (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Give me a number', provider: 'openrouter'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['tool_choice'] === [ - 'type' => 'function', - 'function' => ['name' => 'custom_named_tool'], - ]; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === [ + 'type' => 'function', + 'function' => ['name' => 'custom_named_tool'], + ]); }); -test('none tool choice prevents tool calls', function () { +test('none tool choice prevents tool calls', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Sure')]); (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'openrouter'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'none'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'none'); }); -test('structured output includes json schema response format', function () { +test('structured output includes json schema response format', function (): void { Http::fake(['*' => fakeOpenRouterResponse('{"symbol": "Au"}')]); (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $format = data_get($body, 'response_format'); @@ -152,19 +150,19 @@ }); }); -test('request without schema excludes response format', function () { +test('request without schema excludes response format', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Hello')]); agent()->prompt('Hello', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('response_format', $body); }); }); -test('streaming request includes stream options', function () { +test('streaming request includes stream options', function (): void { Http::fake(['*' => Http::response("data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hi\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1}}\n\ndata: [DONE]\n\n")]); $stream = agent()->stream('Hello', provider: 'openrouter'); @@ -173,7 +171,7 @@ // } - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['stream'] === true @@ -181,7 +179,7 @@ }); }); -test('request sends bearer token authorization', function () { +test('request sends bearer token authorization', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Hello')]); agent()->prompt('Hello', provider: 'openrouter'); @@ -189,7 +187,7 @@ Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('request sends http referer and x openrouter title headers when configured', function () { +test('request sends http referer and x openrouter title headers when configured', function (): void { config(['ai.providers.openrouter' => [ ...config('ai.providers.openrouter'), 'key' => 'test-key', @@ -201,11 +199,11 @@ agent()->prompt('Hello', provider: 'openrouter'); - Http::assertSent(fn (Request $request) => $request->hasHeader('HTTP-Referer', 'https://example.com') + Http::assertSent(fn (Request $request): bool => $request->hasHeader('HTTP-Referer', 'https://example.com') && $request->hasHeader('X-OpenRouter-Title', 'My App')); }); -test('response text is correctly parsed', function () { +test('response text is correctly parsed', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Laravel is great')]); $response = agent()->prompt('Tell me about Laravel', provider: 'openrouter'); @@ -214,7 +212,7 @@ ->and($response->meta->provider)->toBe('openrouter'); }); -test('response usage is correctly parsed', function () { +test('response usage is correctly parsed', function (): void { Http::fake(['*' => Http::response([ 'id' => 'chatcmpl-123', 'object' => 'chat.completion', @@ -236,7 +234,7 @@ ->and($response->usage->completionTokens)->toBe(5); }); -test('response usage includes cache and reasoning tokens', function () { +test('response usage includes cache and reasoning tokens', function (): void { Http::fake(['*' => Http::response([ 'id' => 'chatcmpl-123', 'object' => 'chat.completion', @@ -268,7 +266,7 @@ ->and($response->usage->reasoningTokens)->toBe(10); }); -test('structured response is correctly parsed', function () { +test('structured response is correctly parsed', function (): void { Http::fake(['*' => fakeOpenRouterResponse('{"symbol": "Au"}')]); $response = (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'openrouter'); @@ -276,7 +274,7 @@ expect($response->structured['symbol'])->toBe('Au'); }); -test('web search citations are extracted from message annotations', function () { +test('web search citations are extracted from message annotations', function (): void { Http::fake(['*' => Http::response([ 'id' => 'chatcmpl-123', 'object' => 'chat.completion', @@ -325,7 +323,7 @@ ->and($response->meta->citations[1]->endIndex)->toBe(50); }); -test('web search citations omit span indices when not provided', function () { +test('web search citations omit span indices when not provided', function (): void { Http::fake(['*' => Http::response([ 'id' => 'chatcmpl-123', 'object' => 'chat.completion', @@ -356,7 +354,7 @@ ->and($response->meta->citations[0]->endIndex)->toBeNull(); }); -test('response with no annotations has empty citations collection', function () { +test('response with no annotations has empty citations collection', function (): void { Http::fake(['*' => fakeOpenRouterResponse('Hello')]); $response = agent()->prompt('Hi', provider: 'openrouter'); diff --git a/tests/Feature/Providers/OpenRouter/StreamingTest.php b/tests/Feature/Providers/OpenRouter/StreamingTest.php index 0beeeda23..2e793cfb9 100644 --- a/tests/Feature/Providers/OpenRouter/StreamingTest.php +++ b/tests/Feature/Providers/OpenRouter/StreamingTest.php @@ -15,14 +15,14 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openrouter' => [ ...config('ai.providers.openrouter'), 'key' => 'test-key', ]]); }); -test('streaming emits text events', function () { +test('streaming emits text events', function (): void { Http::fake([ '*' => Http::response($this->ssePayload([ ['id' => 'chatcmpl-1', 'object' => 'chat.completion.chunk', 'model' => 'anthropic/claude-sonnet-4.6', 'choices' => [['index' => 0, 'delta' => ['role' => 'assistant', 'content' => 'Hello'], 'finish_reason' => null]]], @@ -44,12 +44,12 @@ ->toContain(TextEnd::class) ->toContain(StreamEnd::class); - $textDeltas = array_values(array_filter($events, fn ($e) => $e instanceof TextDelta)); + $textDeltas = array_values(array_filter($events, fn ($e): bool => $e instanceof TextDelta)); expect($textDeltas[0]->delta)->toBe('Hello') ->and($textDeltas[1]->delta)->toBe(' world'); }); -test('streaming handles tool calls', function () { +test('streaming handles tool calls', function (): void { Http::fake([ '*' => Http::sequence([ Http::response($this->ssePayload([ @@ -69,9 +69,9 @@ $events[] = $event; } - $toolCallEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolCallEvent)); - $toolResultEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolResultEvent)); - $streamEndEvents = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd)); + $toolCallEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolCallEvent)); + $toolResultEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolResultEvent)); + $streamEndEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd)); expect($toolCallEvents)->toHaveCount(1) ->and($toolCallEvents[0]->toolCall->name)->toBe('FixedNumberGenerator') @@ -81,7 +81,7 @@ ->and($streamEndEvents[0]->reason)->toBe(FinishReason::Stop->value); }); -test('streaming error event stops stream', function () { +test('streaming error event stops stream', function (): void { Http::fake([ '*' => Http::response($this->ssePayload([ ['error' => ['code' => 'server_error', 'message' => 'Internal error']], @@ -93,12 +93,12 @@ $events[] = $event; } - $errorEvents = array_values(array_filter($events, fn ($e) => $e instanceof Error)); + $errorEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof Error)); expect($errorEvents)->toHaveCount(1) ->and($errorEvents[0]->type)->toBe('server_error'); }); -test('streaming error finish reason emits error event', function () { +test('streaming error finish reason emits error event', function (): void { Http::fake([ '*' => Http::response($this->ssePayload([ ['id' => 'chatcmpl-1', 'object' => 'chat.completion.chunk', 'model' => 'anthropic/claude-sonnet-4.6', 'choices' => [['index' => 0, 'delta' => ['role' => 'assistant', 'content' => 'Partial'], 'finish_reason' => null]]], @@ -111,12 +111,12 @@ $events[] = $event; } - $errorEvents = array_values(array_filter($events, fn ($e) => $e instanceof Error)); + $errorEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof Error)); expect($errorEvents)->toHaveCount(1) ->and($errorEvents[0]->type)->toBe('502'); }); -test('streaming captures usage from final chunk', function () { +test('streaming captures usage from final chunk', function (): void { Http::fake([ '*' => Http::response($this->ssePayload([ ['id' => 'chatcmpl-1', 'object' => 'chat.completion.chunk', 'model' => 'anthropic/claude-sonnet-4.6', 'choices' => [['index' => 0, 'delta' => ['role' => 'assistant', 'content' => 'Hi'], 'finish_reason' => null]]], @@ -130,13 +130,13 @@ $events[] = $event; } - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd)); + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd)); expect($streamEnd)->toHaveCount(1) ->and($streamEnd[0]->usage->promptTokens)->toBe(15) ->and($streamEnd[0]->usage->completionTokens)->toBe(3); }); -test('streaming finish reason maps correctly', function (string $apiReason, $expected) { +test('streaming finish reason maps correctly', function (string $apiReason, $expected): void { Http::fake([ '*' => Http::response($this->ssePayload([ ['id' => 'chatcmpl-1', 'object' => 'chat.completion.chunk', 'model' => 'anthropic/claude-sonnet-4.6', 'choices' => [['index' => 0, 'delta' => ['role' => 'assistant', 'content' => 'Hello'], 'finish_reason' => null]]], @@ -149,7 +149,7 @@ $events[] = $event; } - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->reason)->toBe($expected->value); })->with([ @@ -160,7 +160,7 @@ 'unknown maps to Unknown' => ['unknown_reason', FinishReason::Unknown], ]); -test('streaming emits citation events for web search annotations', function () { +test('streaming emits citation events for web search annotations', function (): void { Http::fake([ '*' => Http::response($this->ssePayload([ $this->chatChunk(['role' => 'assistant', 'content' => 'Paris is the capital']), @@ -184,7 +184,7 @@ $events[] = $event; } - $citations = array_values(array_filter($events, fn ($e) => $e instanceof CitationEvent)); + $citations = array_values(array_filter($events, fn ($e): bool => $e instanceof CitationEvent)); expect($citations)->toHaveCount(1) ->and($citations[0]->citation->url)->toBe('https://example.com/paris') @@ -193,7 +193,7 @@ ->and($citations[0]->citation->endIndex)->toBe(30); }); -test('streaming emits multiple citation events across chunks', function () { +test('streaming emits multiple citation events across chunks', function (): void { Http::fake([ '*' => Http::response($this->ssePayload([ $this->chatChunk(['role' => 'assistant', 'content' => 'Answer', 'annotations' => [ @@ -217,14 +217,14 @@ $events[] = $event; } - $citations = array_values(array_filter($events, fn ($e) => $e instanceof CitationEvent)); + $citations = array_values(array_filter($events, fn ($e): bool => $e instanceof CitationEvent)); expect($citations)->toHaveCount(2) ->and($citations[0]->citation->url)->toBe('https://example.com/one') ->and($citations[1]->citation->url)->toBe('https://example.com/two'); }); -test('streaming ignores non-url-citation annotation types', function () { +test('streaming ignores non-url-citation annotation types', function (): void { Http::fake([ '*' => Http::response($this->ssePayload([ $this->chatChunk(['role' => 'assistant', 'content' => 'Answer', 'annotations' => [ @@ -239,7 +239,7 @@ $events[] = $event; } - $citations = array_values(array_filter($events, fn ($e) => $e instanceof CitationEvent)); + $citations = array_values(array_filter($events, fn ($e): bool => $e instanceof CitationEvent)); expect($citations)->toHaveCount(0); }); diff --git a/tests/Feature/Providers/OpenRouter/ToolCallLoopTest.php b/tests/Feature/Providers/OpenRouter/ToolCallLoopTest.php index b550f2b85..a7dc243b3 100644 --- a/tests/Feature/Providers/OpenRouter/ToolCallLoopTest.php +++ b/tests/Feature/Providers/OpenRouter/ToolCallLoopTest.php @@ -7,19 +7,20 @@ use Laravel\Ai\Contracts\HasTools; use Laravel\Ai\Exceptions\NoSuchToolException; use Laravel\Ai\Promptable; +use Laravel\Ai\Responses\AgentResponse; use Tests\Fixtures\Agents\MultiStepToolAgent; use Tests\Fixtures\Tools\FixedNumberGenerator; use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openrouter' => [ ...config('ai.providers.openrouter'), 'key' => 'test-key', ]]); }); -test('tool calls trigger follow up request', function () { +test('tool calls trigger follow up request', function (): void { Http::fake([ '*' => Http::sequence([ fakeOpenRouterToolCallResponse(), @@ -31,10 +32,10 @@ expect($response->text)->toBe('The number is 72019'); - $requests = Http::recorded(fn (Request $r) => true); + $requests = Http::recorded(fn (Request $r): true => true); expect(count($requests))->toBeGreaterThanOrEqual(2); - $followUpBody = json_decode($requests[1][0]->body(), true); + $followUpBody = json_decode((string) $requests[1][0]->body(), true); $messages = $followUpBody['messages']; $assistantMsg = collect($messages)->firstWhere('role', 'assistant'); @@ -46,7 +47,7 @@ ->and($toolMsg['tool_call_id'])->toBe('call_123'); }); -test('max steps limits tool call depth', function () { +test('max steps limits tool call depth', function (): void { Http::fake([ '*' => Http::sequence([ fakeOpenRouterToolCallResponse(), @@ -73,12 +74,12 @@ public function tools(): iterable $agent->prompt('Keep calling tools', provider: 'openrouter'); - $requests = Http::recorded(fn (Request $r) => true); + $requests = Http::recorded(fn (Request $r): true => true); expect(count($requests))->toBeLessThanOrEqual(3); }); -test('multi step tool loop returns accumulated response shape', function () { +test('multi step tool loop returns accumulated response shape', function (): void { Http::fake([ '*' => Http::sequence([ fakeOpenRouterToolCallResponse(), @@ -98,7 +99,7 @@ public function tools(): iterable ->and($response->usage->completionTokens)->toBe(11); }); -test('unregistered tool call throws no such tool exception', function () { +test('unregistered tool call throws no such tool exception', function (): void { Http::fake([ '*' => Http::response([ 'id' => 'chatcmpl-tool-123', @@ -121,6 +122,6 @@ public function tools(): iterable ]), ]); - expect(fn () => agent(tools: [new FixedNumberGenerator])->prompt('Give me a number', provider: 'openrouter')) + expect(fn (): AgentResponse => agent(tools: [new FixedNumberGenerator])->prompt('Give me a number', provider: 'openrouter')) ->toThrow(NoSuchToolException::class); }); diff --git a/tests/Feature/Providers/OpenRouter/ToolMappingTest.php b/tests/Feature/Providers/OpenRouter/ToolMappingTest.php index 763208aac..e502c532a 100644 --- a/tests/Feature/Providers/OpenRouter/ToolMappingTest.php +++ b/tests/Feature/Providers/OpenRouter/ToolMappingTest.php @@ -4,25 +4,26 @@ use Illuminate\Support\Facades\Http; use Laravel\Ai\Providers\Tools\WebFetch; use Laravel\Ai\Providers\Tools\WebSearch; +use Laravel\Ai\Responses\AgentResponse; use Tests\Fixtures\Tools\FixedNumberGenerator; use Tests\Fixtures\Tools\NamedTool; use Tests\Fixtures\Tools\RandomNumberGenerator; use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openrouter' => [ ...config('ai.providers.openrouter'), 'key' => 'test-key', ]]); }); -test('tool with parameters includes correct schema', function () { +test('tool with parameters includes correct schema', function (): void { Http::fake(['*' => fakeOpenRouterResponse('42')]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a random number', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; @@ -36,12 +37,12 @@ }); }); -test('tool with empty schema includes parameters', function () { +test('tool with empty schema includes parameters', function (): void { Http::fake(['*' => fakeOpenRouterResponse('72019')]); agent(tools: [new FixedNumberGenerator])->prompt('Give me a random number', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; @@ -54,12 +55,12 @@ }); }); -test('tool parameters are not wrapped in schema definition', function () { +test('tool parameters are not wrapped in schema definition', function (): void { Http::fake(['*' => fakeOpenRouterResponse('done')]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a random number', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); $function = $tool['function'] ?? []; @@ -69,19 +70,19 @@ }); }); -test('unsupported provider tools throw runtime exception', function () { +test('unsupported provider tools throw runtime exception', function (): void { Http::fake(['*' => fakeOpenRouterResponse('done')]); - expect(fn () => agent(tools: [new WebFetch])->prompt('Search', provider: 'openrouter')) + expect(fn (): AgentResponse => agent(tools: [new WebFetch])->prompt('Search', provider: 'openrouter')) ->toThrow(RuntimeException::class, 'OpenRouter does not support [WebFetch] provider tools.'); }); -test('web search tool is sent as openrouter:web_search type', function () { +test('web search tool is sent as openrouter:web_search type', function (): void { Http::fake(['*' => fakeOpenRouterResponse('done')]); agent(tools: [new WebSearch])->prompt('Search the web', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'openrouter:web_search'); @@ -89,12 +90,12 @@ }); }); -test('web search tool sends max_results when maxSearches is set', function () { +test('web search tool sends max_results when maxSearches is set', function (): void { Http::fake(['*' => fakeOpenRouterResponse('done')]); agent(tools: [(new WebSearch)->max(5)])->prompt('Search the web', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'openrouter:web_search'); @@ -102,12 +103,12 @@ }); }); -test('web search tool sends allowed_domains', function () { +test('web search tool sends allowed_domains', function (): void { Http::fake(['*' => fakeOpenRouterResponse('done')]); agent(tools: [(new WebSearch)->allow(['example.com', 'laravel.com'])])->prompt('Search the web', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'openrouter:web_search'); @@ -115,7 +116,7 @@ }); }); -test('web search tool forwards provider options into parameters', function () { +test('web search tool forwards provider options into parameters', function (): void { Http::fake(['*' => fakeOpenRouterResponse('done')]); $search = (new WebSearch)->withProviderOptions([ @@ -127,7 +128,7 @@ agent(tools: [$search])->prompt('Search the web', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'openrouter:web_search'); @@ -138,12 +139,12 @@ }); }); -test('tool with a name() method emits the declared name', function () { +test('tool with a name() method emits the declared name', function (): void { Http::fake(['*' => fakeOpenRouterResponse('ok')]); agent(tools: [new NamedTool('my_custom_tool')])->prompt('Hi', provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $names = collect(data_get($body, 'tools'))->pluck('function.name')->all(); diff --git a/tests/Feature/Providers/OpenRouter/TranscriptionTest.php b/tests/Feature/Providers/OpenRouter/TranscriptionTest.php index 57f3f0eb2..10e8a5fc3 100644 --- a/tests/Feature/Providers/OpenRouter/TranscriptionTest.php +++ b/tests/Feature/Providers/OpenRouter/TranscriptionTest.php @@ -6,9 +6,10 @@ use Illuminate\Support\Facades\Http; use Laravel\Ai\Exceptions\ProviderOverloadedException; use Laravel\Ai\Exceptions\RateLimitedException; +use Laravel\Ai\Responses\TranscriptionResponse; use Laravel\Ai\Transcription; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.openrouter' => [ ...config('ai.providers.openrouter'), 'key' => 'test-key', @@ -29,23 +30,21 @@ function fakeOpenRouterTranscriptionResponse(string $text = 'Hello, world!'): Pr ]); } -test('transcription request posts to correct endpoint as json', function () { +test('transcription request posts to correct endpoint as json', function (): void { Http::fake(['*' => fakeOpenRouterTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3')->generate(provider: 'openrouter'); - Http::assertSent(function (Request $request) { - return $request->url() === 'https://openrouter.ai/api/v1/audio/transcriptions' - && str_contains($request->header('Content-Type')[0] ?? '', 'application/json'); - }); + Http::assertSent(fn (Request $request): bool => $request->url() === 'https://openrouter.ai/api/v1/audio/transcriptions' + && str_contains($request->header('Content-Type')[0] ?? '', 'application/json')); }); -test('transcription request sends audio as base64 with format from mime type', function () { +test('transcription request sends audio as base64 with format from mime type', function (): void { Http::fake(['*' => fakeOpenRouterTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3')->generate(provider: 'openrouter'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['input_audio']['data'] === base64_encode('fake-audio') @@ -53,10 +52,10 @@ function fakeOpenRouterTranscriptionResponse(string $text = 'Hello, world!'): Pr }); }); -test('transcription diarize throws logic exception without sending request', function () { +test('transcription diarize throws logic exception without sending request', function (): void { Http::fake(['*' => fakeOpenRouterTranscriptionResponse()]); - expect(fn () => Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') + expect(fn (): TranscriptionResponse => Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->diarize() ->generate(provider: 'openrouter')) ->toThrow(LogicException::class, 'OpenRouter does not support diarized transcription'); @@ -64,14 +63,12 @@ function fakeOpenRouterTranscriptionResponse(string $text = 'Hello, world!'): Pr Http::assertNothingSent(); }); -test('transcription maps audio mime types to openrouter format values', function (string $mimeType, string $expectedFormat) { +test('transcription maps audio mime types to openrouter format values', function (string $mimeType, string $expectedFormat): void { Http::fake(['*' => fakeOpenRouterTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), $mimeType)->generate(provider: 'openrouter'); - Http::assertSent(function (Request $request) use ($expectedFormat) { - return json_decode($request->body(), true)['input_audio']['format'] === $expectedFormat; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['input_audio']['format'] === $expectedFormat); })->with([ 'mp3 via audio/mpeg' => ['audio/mpeg', 'mp3'], 'mp3 via audio/mp3' => ['audio/mp3', 'mp3'], @@ -88,17 +85,17 @@ function fakeOpenRouterTranscriptionResponse(string $text = 'Hello, world!'): Pr 'aac via audio/aac' => ['audio/aac', 'aac'], ]); -test('transcription wraps raw pcm audio in a wav header and sends as wav format', function () { +test('transcription wraps raw pcm audio in a wav header and sends as wav format', function (): void { Http::fake(['*' => fakeOpenRouterTranscriptionResponse()]); $pcm = str_repeat("\x01\x00", 1000); Transcription::fromBase64(base64_encode($pcm), 'audio/pcm')->generate(provider: 'openrouter'); - Http::assertSent(function (Request $request) use ($pcm) { + Http::assertSent(function (Request $request) use ($pcm): bool { $body = json_decode($request->body(), true); - $sent = base64_decode($body['input_audio']['data']); + $sent = base64_decode((string) $body['input_audio']['data']); return $body['input_audio']['format'] === 'wav' && str_starts_with($sent, 'RIFF') @@ -107,49 +104,43 @@ function fakeOpenRouterTranscriptionResponse(string $text = 'Hello, world!'): Pr }); }); -test('transcription throws invalid argument exception for unsupported mime type', function () { +test('transcription throws invalid argument exception for unsupported mime type', function (): void { Http::fake(); - expect(fn () => Transcription::fromBase64(base64_encode('fake-audio'), 'audio/x-aiff') + expect(fn (): TranscriptionResponse => Transcription::fromBase64(base64_encode('fake-audio'), 'audio/x-aiff') ->generate(provider: 'openrouter')) ->toThrow(InvalidArgumentException::class, 'Unsupported audio MIME type [audio/x-aiff]'); Http::assertNothingSent(); }); -test('transcription request includes language when specified', function () { +test('transcription request includes language when specified', function (): void { Http::fake(['*' => fakeOpenRouterTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->language('fr') ->generate(provider: 'openrouter'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['language'] === 'fr'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['language'] === 'fr'); }); -test('transcription request omits language when not specified', function () { +test('transcription request omits language when not specified', function (): void { Http::fake(['*' => fakeOpenRouterTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3')->generate(provider: 'openrouter'); - Http::assertSent(function (Request $request) { - return ! array_key_exists('language', json_decode($request->body(), true)); - }); + Http::assertSent(fn (Request $request): bool => ! array_key_exists('language', json_decode($request->body(), true))); }); -test('transcription uses default model when none specified', function () { +test('transcription uses default model when none specified', function (): void { Http::fake(['*' => fakeOpenRouterTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3')->generate(provider: 'openrouter'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['model'] === 'openai/whisper-1'; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['model'] === 'openai/whisper-1'); }); -test('transcription response text is correctly parsed', function () { +test('transcription response text is correctly parsed', function (): void { Http::fake(['*' => fakeOpenRouterTranscriptionResponse('Hello, world!')]); $response = Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3')->generate(provider: 'openrouter'); @@ -160,7 +151,7 @@ function fakeOpenRouterTranscriptionResponse(string $text = 'Hello, world!'): Pr ->and($response->meta->model)->toBe('openai/whisper-1'); }); -test('transcription usage is correctly parsed', function () { +test('transcription usage is correctly parsed', function (): void { Http::fake(['*' => Http::response([ 'text' => 'Hello', 'usage' => [ @@ -178,7 +169,7 @@ function fakeOpenRouterTranscriptionResponse(string $text = 'Hello, world!'): Pr ->and($response->usage->completionTokens)->toBe(50); }); -test('transcription request sends bearer token', function () { +test('transcription request sends bearer token', function (): void { Http::fake(['*' => fakeOpenRouterTranscriptionResponse()]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3')->generate(provider: 'openrouter'); @@ -186,7 +177,7 @@ function fakeOpenRouterTranscriptionResponse(string $text = 'Hello, world!'): Pr Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('transcription rate limit response throws rate limited exception', function () { +test('transcription rate limit response throws rate limited exception', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response([ 'error' => [ @@ -200,7 +191,7 @@ function fakeOpenRouterTranscriptionResponse(string $text = 'Hello, world!'): Pr ->generate(provider: 'openrouter', model: 'openai/whisper-1'); })->throws(RateLimitedException::class); -test('transcription overloaded response throws provider overloaded exception', function () { +test('transcription overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response([ 'error' => [ @@ -214,7 +205,7 @@ function fakeOpenRouterTranscriptionResponse(string $text = 'Hello, world!'): Pr ->generate(provider: 'openrouter', model: 'openai/whisper-1'); })->throws(ProviderOverloadedException::class); -test('transcription http error response throws request exception', function () { +test('transcription http error response throws request exception', function (): void { Http::fake([ 'openrouter.ai/*' => Http::response([ 'error' => [ diff --git a/tests/Feature/Providers/TextGenerationOptionsInToolLoopTest.php b/tests/Feature/Providers/TextGenerationOptionsInToolLoopTest.php index cceb20054..22d481854 100644 --- a/tests/Feature/Providers/TextGenerationOptionsInToolLoopTest.php +++ b/tests/Feature/Providers/TextGenerationOptionsInToolLoopTest.php @@ -107,7 +107,7 @@ function textGenFakeGroqTextResponse(string $text): PromiseInterface ]); } -test('openai temperature and max tokens are preserved in tool call follow up', function () { +test('openai temperature and max tokens are preserved in tool call follow up', function (): void { Http::fake([ '*' => Http::sequence([ textGenFakeOpenAiToolCallResponse(), @@ -120,22 +120,22 @@ function textGenFakeGroqTextResponse(string $text): PromiseInterface $gateway = new OpenAiGateway(app(Dispatcher::class)); $manager = app(AiManager::class); $manager->purge('openai'); - $manager->extend('openai', fn ($app, array $config) => new OpenAiProvider( + $manager->extend('openai', fn ($app, array $config): OpenAiProvider => new OpenAiProvider( $gateway, $config, app(Dispatcher::class), )); (new TextGenOptionsToolAgent)->prompt('Give me a number', provider: 'openai'); Http::assertSentInOrder([ - fn (Request $request) => $request['temperature'] === 0.2 + fn (Request $request): bool => $request['temperature'] === 0.2 && $request['max_output_tokens'] === 1024, - fn (Request $request) => $request['temperature'] === 0.2 + fn (Request $request): bool => $request['temperature'] === 0.2 && $request['max_output_tokens'] === 1024 && isset($request['previous_response_id']), ]); }); -test('groq temperature and max tokens are preserved in tool call follow up', function () { +test('groq temperature and max tokens are preserved in tool call follow up', function (): void { Http::fake([ '*' => Http::sequence([ textGenFakeGroqToolCallResponse(), @@ -148,7 +148,7 @@ function textGenFakeGroqTextResponse(string $text): PromiseInterface $gateway = new GroqGateway(app(Dispatcher::class)); $manager = app(AiManager::class); $manager->purge('groq'); - $manager->extend('groq', function ($app, array $config) use ($gateway) { + $manager->extend('groq', function ($app, array $config) use ($gateway): GroqProvider { $provider = new GroqProvider($config, app(Dispatcher::class)); $provider->useTextGateway($gateway); @@ -158,9 +158,9 @@ function textGenFakeGroqTextResponse(string $text): PromiseInterface (new TextGenOptionsToolAgent)->prompt('Give me a number', provider: 'groq'); Http::assertSentInOrder([ - fn (Request $request) => $request['temperature'] === 0.2 + fn (Request $request): bool => $request['temperature'] === 0.2 && $request['max_completion_tokens'] === 1024, - fn (Request $request) => $request['temperature'] === 0.2 + fn (Request $request): bool => $request['temperature'] === 0.2 && $request['max_completion_tokens'] === 1024, ]); }); diff --git a/tests/Feature/Providers/TimeoutInToolLoopTest.php b/tests/Feature/Providers/TimeoutInToolLoopTest.php index 050f15b4e..40cf844ef 100644 --- a/tests/Feature/Providers/TimeoutInToolLoopTest.php +++ b/tests/Feature/Providers/TimeoutInToolLoopTest.php @@ -40,6 +40,7 @@ class SpyOpenAiGateway extends OpenAiGateway { public array $capturedTimeouts = []; + #[Override] protected function client(Provider $provider, ?int $timeout = null): PendingRequest { $this->capturedTimeouts[] = $timeout; @@ -52,6 +53,7 @@ class SpyAnthropicGateway extends AnthropicGateway { public array $capturedTimeouts = []; + #[Override] protected function client(Provider $provider, ?int $timeout = null): PendingRequest { $this->capturedTimeouts[] = $timeout; @@ -64,6 +66,7 @@ class SpyGroqGateway extends GroqGateway { public array $capturedTimeouts = []; + #[Override] protected function client(Provider $provider, ?int $timeout = null): PendingRequest { $this->capturedTimeouts[] = $timeout; @@ -76,6 +79,7 @@ class SpyGeminiGateway extends GeminiGateway { public array $capturedTimeouts = []; + #[Override] protected function client(Provider $provider, ?int $timeout = null): PendingRequest { $this->capturedTimeouts[] = $timeout; @@ -233,7 +237,7 @@ function timeoutFakeGeminiTextResponse(string $text): PromiseInterface ]); } -test('openai timeout is preserved in tool call follow up', function () { +test('openai timeout is preserved in tool call follow up', function (): void { Http::fake([ '*' => Http::sequence([ timeoutFakeOpenAiToolCallResponse(), @@ -246,7 +250,7 @@ function timeoutFakeGeminiTextResponse(string $text): PromiseInterface $spy = new SpyOpenAiGateway(app(Dispatcher::class)); $manager = app(AiManager::class); $manager->purge('openai'); - $manager->extend('openai', fn ($app, array $config) => new OpenAiProvider( + $manager->extend('openai', fn ($app, array $config): OpenAiProvider => new OpenAiProvider( $spy, $config, app(Dispatcher::class), )); @@ -257,7 +261,7 @@ function timeoutFakeGeminiTextResponse(string $text): PromiseInterface ->and($spy->capturedTimeouts[1])->toBe(300); }); -test('anthropic timeout is preserved in tool call follow up', function () { +test('anthropic timeout is preserved in tool call follow up', function (): void { Http::fake([ 'api.anthropic.com/*' => Http::sequence([ timeoutFakeAnthropicToolCallResponse(), @@ -270,7 +274,7 @@ function timeoutFakeGeminiTextResponse(string $text): PromiseInterface $spy = new SpyAnthropicGateway(app(Dispatcher::class)); $manager = app(AiManager::class); $manager->purge('anthropic'); - $manager->extend('anthropic', fn ($app, array $config) => new AnthropicProvider( + $manager->extend('anthropic', fn ($app, array $config): AnthropicProvider => new AnthropicProvider( $spy, $config, app(Dispatcher::class), )); @@ -281,7 +285,7 @@ function timeoutFakeGeminiTextResponse(string $text): PromiseInterface ->and($spy->capturedTimeouts[1])->toBe(300); }); -test('groq timeout is preserved in tool call follow up', function () { +test('groq timeout is preserved in tool call follow up', function (): void { Http::fake([ '*' => Http::sequence([ timeoutFakeGroqToolCallResponse(), @@ -295,7 +299,7 @@ function timeoutFakeGeminiTextResponse(string $text): PromiseInterface $manager = app(AiManager::class); $manager->purge('groq'); - $manager->extend('groq', function ($app, array $config) use ($spy) { + $manager->extend('groq', function ($app, array $config) use ($spy): GroqProvider { $provider = new GroqProvider($config, app(Dispatcher::class)); $provider->useTextGateway($spy); @@ -309,7 +313,7 @@ function timeoutFakeGeminiTextResponse(string $text): PromiseInterface ->and($spy->capturedTimeouts[1])->toBe(300); }); -test('gemini timeout is preserved in tool call follow up', function () { +test('gemini timeout is preserved in tool call follow up', function (): void { Http::fake([ 'generativelanguage.googleapis.com/*' => Http::sequence([ timeoutFakeGeminiToolCallResponse(), @@ -322,7 +326,7 @@ function timeoutFakeGeminiTextResponse(string $text): PromiseInterface $spy = new SpyGeminiGateway(app(Dispatcher::class)); $manager = app(AiManager::class); $manager->purge('gemini'); - $manager->extend('gemini', fn ($app, array $config) => new GeminiProvider( + $manager->extend('gemini', fn ($app, array $config): GeminiProvider => new GeminiProvider( $spy, $config, app(Dispatcher::class), )); diff --git a/tests/Feature/Providers/VoyageAi/BaseUrlTest.php b/tests/Feature/Providers/VoyageAi/BaseUrlTest.php index 459fe3827..101c93892 100644 --- a/tests/Feature/Providers/VoyageAi/BaseUrlTest.php +++ b/tests/Feature/Providers/VoyageAi/BaseUrlTest.php @@ -26,7 +26,7 @@ function fakeVoyageBaseUrlRerankingResponse(): PromiseInterface ]); } -test('voyageai embedding requests use the configured base url', function () { +test('voyageai embedding requests use the configured base url', function (): void { config(['ai.providers.voyageai' => [ ...config('ai.providers.voyageai'), 'key' => 'test-key', @@ -37,10 +37,10 @@ function fakeVoyageBaseUrlRerankingResponse(): PromiseInterface Embeddings::for(['Hello'])->generate(provider: 'voyageai', model: 'voyage-4'); - Http::assertSent(fn (Request $r) => $r->url() === 'http://localhost:8080/v1/embeddings'); + Http::assertSent(fn (Request $r): bool => $r->url() === 'http://localhost:8080/v1/embeddings'); }); -test('voyageai reranking requests use the configured base url', function () { +test('voyageai reranking requests use the configured base url', function (): void { config(['ai.providers.voyageai' => [ ...config('ai.providers.voyageai'), 'key' => 'test-key', @@ -51,10 +51,10 @@ function fakeVoyageBaseUrlRerankingResponse(): PromiseInterface Reranking::of(['doc1'])->rerank('What is AI?', provider: 'voyageai', model: 'rerank-2.5-lite'); - Http::assertSent(fn (Request $r) => $r->url() === 'http://localhost:8080/v1/rerank'); + Http::assertSent(fn (Request $r): bool => $r->url() === 'http://localhost:8080/v1/rerank'); }); -test('voyageai requests fall back to the default base url', function () { +test('voyageai requests fall back to the default base url', function (): void { config(['ai.providers.voyageai' => array_diff_key( [...config('ai.providers.voyageai'), 'key' => 'test-key'], ['url' => null], @@ -64,5 +64,5 @@ function fakeVoyageBaseUrlRerankingResponse(): PromiseInterface Embeddings::for(['Hello'])->generate(provider: 'voyageai', model: 'voyage-4'); - Http::assertSent(fn (Request $r) => $r->url() === 'https://api.voyageai.com/v1/embeddings'); + Http::assertSent(fn (Request $r): bool => $r->url() === 'https://api.voyageai.com/v1/embeddings'); }); diff --git a/tests/Feature/Providers/VoyageAi/EmbeddingTest.php b/tests/Feature/Providers/VoyageAi/EmbeddingTest.php index f2e4c3900..826ddc2a1 100644 --- a/tests/Feature/Providers/VoyageAi/EmbeddingTest.php +++ b/tests/Feature/Providers/VoyageAi/EmbeddingTest.php @@ -7,19 +7,19 @@ use Laravel\Ai\Exceptions\ProviderOverloadedException; use Laravel\Ai\Exceptions\RateLimitedException; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.voyageai' => [ ...config('ai.providers.voyageai'), 'key' => 'test-key', ]]); }); -test('embeddings request includes model, input, and output_dimension', function () { +test('embeddings request includes model, input, and output_dimension', function (): void { Http::fake(['*' => fakeVoyageEmbeddingsResponse()]); Embeddings::for(['Hello world'])->dimensions(1024)->generate(provider: 'voyageai', model: 'voyage-4'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'voyage-4' @@ -29,7 +29,7 @@ }); }); -test('embeddings response is correctly parsed', function () { +test('embeddings response is correctly parsed', function (): void { Http::fake(['*' => fakeVoyageEmbeddingsResponse()]); $response = Embeddings::for(['Hello world'])->generate(provider: 'voyageai', model: 'voyage-4'); @@ -41,17 +41,15 @@ ->and($response->meta->model)->toBe('voyage-4'); }); -test('embeddings request sends bearer token', function () { +test('embeddings request sends bearer token', function (): void { Http::fake(['*' => fakeVoyageEmbeddingsResponse()]); Embeddings::for(['Hello'])->generate(provider: 'voyageai', model: 'voyage-4'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('multiple inputs return multiple embeddings', function () { +test('multiple inputs return multiple embeddings', function (): void { Http::fake(['*' => Http::response([ 'object' => 'list', 'data' => [ @@ -68,15 +66,15 @@ ->and($response->embeddings[1])->toBe([0.4, 0.5, 0.6]); }); -test('embeddings default to 1024 dimensions when none specified', function () { +test('embeddings default to 1024 dimensions when none specified', function (): void { Http::fake(['*' => fakeVoyageEmbeddingsResponse()]); Embeddings::for(['Hello'])->generate(provider: 'voyageai', model: 'voyage-4'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['output_dimension'] === 1024); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['output_dimension'] === 1024); }); -test('embeddings rate limit response throws rate limited exception', function () { +test('embeddings rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.voyageai.com/*' => Http::response(['detail' => 'Rate limit exceeded'], 429), ]); @@ -84,7 +82,7 @@ Embeddings::for(['Hello'])->generate(provider: 'voyageai', model: 'voyage-4'); })->throws(RateLimitedException::class); -test('embeddings overloaded response throws provider overloaded exception', function () { +test('embeddings overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.voyageai.com/*' => Http::response(['detail' => 'Service unavailable'], 503), ]); @@ -92,7 +90,7 @@ Embeddings::for(['Hello'])->generate(provider: 'voyageai', model: 'voyage-4'); })->throws(ProviderOverloadedException::class); -test('embeddings http error response throws request exception', function () { +test('embeddings http error response throws request exception', function (): void { Http::fake([ 'api.voyageai.com/*' => Http::response(['detail' => 'Invalid model'], 400), ]); @@ -100,14 +98,14 @@ Embeddings::for(['Hello'])->generate(provider: 'voyageai', model: 'voyage-4'); })->throws(RequestException::class); -test('embeddings request includes provider options in the request body', function () { +test('embeddings request includes provider options in the request body', function (): void { Http::fake(['*' => fakeVoyageEmbeddingsResponse()]); Embeddings::for(['Hello']) ->withProviderOptions(['input_type' => 'query', 'truncation' => true]) ->generate(provider: 'voyageai', model: 'voyage-4'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['input_type'] === 'query' diff --git a/tests/Feature/Providers/VoyageAi/ErrorHandlingTest.php b/tests/Feature/Providers/VoyageAi/ErrorHandlingTest.php index 1c34b7e52..b9a4081e2 100644 --- a/tests/Feature/Providers/VoyageAi/ErrorHandlingTest.php +++ b/tests/Feature/Providers/VoyageAi/ErrorHandlingTest.php @@ -7,14 +7,14 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Laravel\Ai\Reranking; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.voyageai' => [ ...config('ai.providers.voyageai'), 'key' => 'test-key', ]]); }); -test('embeddings rate limit response throws rate limited exception', function () { +test('embeddings rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.voyageai.com/*' => Http::response(['detail' => 'Rate limit exceeded'], 429), ]); @@ -22,7 +22,7 @@ Embeddings::for(['Hello'])->generate(provider: 'voyageai', model: 'voyage-4'); })->throws(RateLimitedException::class); -test('embeddings overloaded response throws provider overloaded exception', function () { +test('embeddings overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.voyageai.com/*' => Http::response(['detail' => 'Service overloaded'], 503), ]); @@ -30,7 +30,7 @@ Embeddings::for(['Hello'])->generate(provider: 'voyageai', model: 'voyage-4'); })->throws(ProviderOverloadedException::class); -test('embeddings http error response throws request exception', function () { +test('embeddings http error response throws request exception', function (): void { Http::fake([ 'api.voyageai.com/*' => Http::response(['detail' => 'Unauthorized'], 401), ]); @@ -38,7 +38,7 @@ Embeddings::for(['Hello'])->generate(provider: 'voyageai', model: 'voyage-4'); })->throws(RequestException::class); -test('reranking rate limit response throws rate limited exception', function () { +test('reranking rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.voyageai.com/*' => Http::response(['detail' => 'Rate limit exceeded'], 429), ]); @@ -46,7 +46,7 @@ Reranking::of(['doc1'])->rerank('What is AI?', provider: 'voyageai', model: 'rerank-2.5-lite'); })->throws(RateLimitedException::class); -test('reranking overloaded response throws provider overloaded exception', function () { +test('reranking overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.voyageai.com/*' => Http::response(['detail' => 'Service overloaded'], 503), ]); @@ -54,7 +54,7 @@ Reranking::of(['doc1'])->rerank('What is AI?', provider: 'voyageai', model: 'rerank-2.5-lite'); })->throws(ProviderOverloadedException::class); -test('reranking http error response throws request exception', function () { +test('reranking http error response throws request exception', function (): void { Http::fake([ 'api.voyageai.com/*' => Http::response(['detail' => 'Unauthorized'], 401), ]); diff --git a/tests/Feature/Providers/VoyageAi/RerankingTest.php b/tests/Feature/Providers/VoyageAi/RerankingTest.php index 1f16f5a8f..a81360116 100644 --- a/tests/Feature/Providers/VoyageAi/RerankingTest.php +++ b/tests/Feature/Providers/VoyageAi/RerankingTest.php @@ -8,20 +8,20 @@ use Laravel\Ai\Reranking; use Laravel\Ai\Responses\Data\RankedDocument; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.voyageai' => [ ...config('ai.providers.voyageai'), 'key' => 'test-key', ]]); }); -test('reranking request includes model, query, and documents', function () { +test('reranking request includes model, query, and documents', function (): void { Http::fake(['*' => fakeVoyageRerankingResponse()]); Reranking::of(['Laravel is a PHP framework', 'React is a JS library']) ->rerank('What is Laravel?', provider: 'voyageai', model: 'rerank-2.5-lite'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'rerank-2.5-lite' @@ -32,17 +32,17 @@ }); }); -test('reranking request includes top_k when limit set', function () { +test('reranking request includes top_k when limit set', function (): void { Http::fake(['*' => fakeVoyageRerankingResponse()]); Reranking::of(['Doc A', 'Doc B', 'Doc C']) ->limit(2) ->rerank('query', provider: 'voyageai', model: 'rerank-2.5-lite'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['top_k'] === 2); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['top_k'] === 2); }); -test('reranking response is correctly parsed into RankedDocuments', function () { +test('reranking response is correctly parsed into RankedDocuments', function (): void { Http::fake(['*' => fakeVoyageRerankingResponse()]); $response = Reranking::of(['Laravel is a PHP framework', 'React is a JS library']) @@ -57,7 +57,7 @@ ->and($response->meta->model)->toBe('rerank-2.5-lite'); }); -test('reranking request sends bearer token', function () { +test('reranking request sends bearer token', function (): void { Http::fake(['*' => fakeVoyageRerankingResponse()]); Reranking::of(['Doc A', 'Doc B'])->rerank('query', provider: 'voyageai', model: 'rerank-2.5-lite'); @@ -65,7 +65,7 @@ Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('reranking rate limit response throws rate limited exception', function () { +test('reranking rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.voyageai.com/*' => Http::response(['detail' => 'Rate limit exceeded'], 429), ]); @@ -73,7 +73,7 @@ Reranking::of(['Doc A', 'Doc B'])->rerank('query', provider: 'voyageai', model: 'rerank-2.5-lite'); })->throws(RateLimitedException::class); -test('reranking overloaded response throws provider overloaded exception', function () { +test('reranking overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.voyageai.com/*' => Http::response(['detail' => 'Service unavailable'], 503), ]); @@ -81,7 +81,7 @@ Reranking::of(['Doc A', 'Doc B'])->rerank('query', provider: 'voyageai', model: 'rerank-2.5-lite'); })->throws(ProviderOverloadedException::class); -test('reranking http error response throws request exception', function () { +test('reranking http error response throws request exception', function (): void { Http::fake([ 'api.voyageai.com/*' => Http::response(['detail' => 'Invalid model'], 400), ]); diff --git a/tests/Feature/Providers/Xai/AgentFakeTest.php b/tests/Feature/Providers/Xai/AgentFakeTest.php index 9f1e87821..5f690ee5b 100644 --- a/tests/Feature/Providers/Xai/AgentFakeTest.php +++ b/tests/Feature/Providers/Xai/AgentFakeTest.php @@ -2,7 +2,7 @@ use Tests\Fixtures\Agents\XaiAgent; -test('xai agent can be faked', function () { +test('xai agent can be faked', function (): void { XaiAgent::fake(['Test response']); $response = (new XaiAgent)->prompt('Hello'); @@ -10,15 +10,15 @@ expect($response->text)->toBe('Test response'); }); -test('xai agent fake with closure', function () { - XaiAgent::fake(fn (string $prompt) => "Echo: {$prompt}"); +test('xai agent fake with closure', function (): void { + XaiAgent::fake(fn (string $prompt): string => "Echo: {$prompt}"); $response = (new XaiAgent)->prompt('Hello world'); expect($response->text)->toBe('Echo: Hello world'); }); -test('xai agent fake with no predefined responses', function () { +test('xai agent fake with no predefined responses', function (): void { XaiAgent::fake(); $response = (new XaiAgent)->prompt('Hello'); @@ -26,7 +26,7 @@ expect($response->text)->toBe('Fake response for prompt: Hello'); }); -test('xai agent fake records prompts', function () { +test('xai agent fake records prompts', function (): void { XaiAgent::fake(); (new XaiAgent)->prompt('Hello'); @@ -35,11 +35,11 @@ XaiAgent::assertNotPrompted('Goodbye'); }); -test('xai agent stream can be faked', function () { +test('xai agent stream can be faked', function (): void { XaiAgent::fake(['Streamed response']); $response = (new XaiAgent)->stream('Hello'); - $response->each(fn () => true); + $response->each(fn (): true => true); expect($response->text)->toBe('Streamed response'); }); diff --git a/tests/Feature/Providers/Xai/BaseUrlTest.php b/tests/Feature/Providers/Xai/BaseUrlTest.php index 2d3a49f06..71300eaf0 100644 --- a/tests/Feature/Providers/Xai/BaseUrlTest.php +++ b/tests/Feature/Providers/Xai/BaseUrlTest.php @@ -5,11 +5,11 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { $this->customUrl = 'http://localhost:1234/v1'; }); -test('xai text requests use the configured base url', function () { +test('xai text requests use the configured base url', function (): void { config(['ai.providers.xai' => array_filter([ ...config('ai.providers.xai'), 'key' => 'test-key', @@ -25,11 +25,11 @@ expect($response->text)->toBe('Hello from local model'); Http::assertSentCount(1); - Http::assertSent(fn (Request $request) => $request->method() === 'POST' + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->url() === "{$this->customUrl}/responses"); }); -test('xai requests fall back to the default base url', function () { +test('xai requests fall back to the default base url', function (): void { config(['ai.providers.xai' => array_filter([ ...config('ai.providers.xai'), 'key' => 'test-key', @@ -44,7 +44,7 @@ expect($response->text)->toBe('Hello from xAI'); Http::assertSentCount(1); - Http::assertSent(fn (Request $request) => $request->method() === 'POST' + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->url() === 'https://api.x.ai/v1/responses'); }); diff --git a/tests/Feature/Providers/Xai/ErrorHandlingTest.php b/tests/Feature/Providers/Xai/ErrorHandlingTest.php index 8e614baee..b79d112f5 100644 --- a/tests/Feature/Providers/Xai/ErrorHandlingTest.php +++ b/tests/Feature/Providers/Xai/ErrorHandlingTest.php @@ -7,14 +7,14 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Tests\Fixtures\Agents\AssistantAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.xai' => [ ...config('ai.providers.xai'), 'key' => 'test-key', ]]); }); -test('http error response throws request exception', function () { +test('http error response throws request exception', function (): void { Http::fake([ '*' => Http::response([ 'error' => [ @@ -27,7 +27,7 @@ (new AssistantAgent)->prompt('Hi', provider: 'xai'); })->throws(RequestException::class); -test('rate limit response throws rate limited exception', function () { +test('rate limit response throws rate limited exception', function (): void { Http::fake([ '*' => Http::response([ 'error' => [ @@ -40,7 +40,7 @@ (new AssistantAgent)->prompt('Hi', provider: 'xai'); })->throws(RateLimitedException::class); -test('overloaded response throws provider overloaded exception', function () { +test('overloaded response throws provider overloaded exception', function (): void { Http::fake([ '*' => Http::response([ 'error' => [ @@ -53,7 +53,7 @@ (new AssistantAgent)->prompt('Hi', provider: 'xai'); })->throws(ProviderOverloadedException::class); -test('error in 200 response throws ai exception', function () { +test('error in 200 response throws ai exception', function (): void { Http::fake([ '*' => Http::response([ 'error' => [ @@ -66,7 +66,7 @@ (new AssistantAgent)->prompt('Hi', provider: 'xai'); })->throws(AiException::class, 'xAI Error'); -test('failed status response throws ai exception', function () { +test('failed status response throws ai exception', function (): void { Http::fake([ '*' => Http::response([ 'id' => 'resp_123', diff --git a/tests/Feature/Providers/Xai/ImageGenerationTest.php b/tests/Feature/Providers/Xai/ImageGenerationTest.php index ed20c0e3c..e312fb18f 100644 --- a/tests/Feature/Providers/Xai/ImageGenerationTest.php +++ b/tests/Feature/Providers/Xai/ImageGenerationTest.php @@ -8,7 +8,7 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Laravel\Ai\Image; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.xai' => [ ...config('ai.providers.xai'), 'key' => 'test-key', @@ -24,14 +24,14 @@ function fakeXaiImageResponse(): PromiseInterface ]); } -test('image request includes model, prompt, and b64_json response format', function () { +test('image request includes model, prompt, and b64_json response format', function (): void { Http::fake([ '*' => fakeXaiImageResponse(), ]); Image::of('A red apple')->generate(provider: 'xai', model: 'grok-imagine-image'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'grok-imagine-image' @@ -42,35 +42,35 @@ function fakeXaiImageResponse(): PromiseInterface }); }); -test('omits size when provided because xAI does not support it', function () { +test('omits size when provided because xAI does not support it', function (): void { Http::fake([ '*' => fakeXaiImageResponse(), ]); Image::of('A red apple')->square()->generate(provider: 'xai', model: 'grok-imagine-image'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('size', $body); }); }); -test('omits quality when provided because xAI does not support it', function () { +test('omits quality when provided because xAI does not support it', function (): void { Http::fake([ '*' => fakeXaiImageResponse(), ]); Image::of('A red apple')->quality('high')->generate(provider: 'xai', model: 'grok-imagine-image'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('quality', $body); }); }); -test('image response is correctly parsed', function () { +test('image response is correctly parsed', function (): void { Http::fake([ '*' => fakeXaiImageResponse(), ]); @@ -83,19 +83,17 @@ function fakeXaiImageResponse(): PromiseInterface ->and($response->meta->provider)->toBe('xai'); }); -test('request sends bearer token authorization', function () { +test('request sends bearer token authorization', function (): void { Http::fake([ '*' => fakeXaiImageResponse(), ]); Image::of('A red apple')->generate(provider: 'xai', model: 'grok-imagine-image'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('image rate limit response throws rate limited exception', function () { +test('image rate limit response throws rate limited exception', function (): void { Http::fake([ 'api.x.ai/*' => Http::response([ 'error' => [ @@ -108,7 +106,7 @@ function fakeXaiImageResponse(): PromiseInterface Image::of('A red apple')->generate(provider: 'xai', model: 'grok-imagine-image'); })->throws(RateLimitedException::class); -test('image overloaded response throws provider overloaded exception', function () { +test('image overloaded response throws provider overloaded exception', function (): void { Http::fake([ 'api.x.ai/*' => Http::response([ 'error' => [ @@ -121,7 +119,7 @@ function fakeXaiImageResponse(): PromiseInterface Image::of('A red apple')->generate(provider: 'xai', model: 'grok-imagine-image'); })->throws(ProviderOverloadedException::class); -test('image http error response throws request exception', function () { +test('image http error response throws request exception', function (): void { Http::fake([ 'api.x.ai/*' => Http::response([ 'error' => [ diff --git a/tests/Feature/Providers/Xai/MessageMappingTest.php b/tests/Feature/Providers/Xai/MessageMappingTest.php index eb90f33d5..d689d5d02 100644 --- a/tests/Feature/Providers/Xai/MessageMappingTest.php +++ b/tests/Feature/Providers/Xai/MessageMappingTest.php @@ -16,30 +16,30 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.xai' => [ ...config('ai.providers.xai'), 'key' => 'test-key', ]]); }); -test('user message maps to responses api format', function () { +test('user message maps to responses api format', function (): void { Http::fake(['*' => $this->fakeTextResponse()]); (new AssistantAgent)->prompt('What is Laravel?', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMsg = collect($body['input'])->firstWhere('role', 'user'); return $userMsg !== null && collect($userMsg['content'])->contains( - fn ($c) => ($c['type'] ?? '') === 'input_text' && $c['text'] === 'What is Laravel?' + fn ($c): bool => ($c['type'] ?? '') === 'input_text' && $c['text'] === 'What is Laravel?' ); }); }); -test('tool call follow up uses previous response id', function () { +test('tool call follow up uses previous response id', function (): void { Http::fake([ '*' => Http::sequence([ $this->fakeToolCallResponse(), @@ -53,19 +53,19 @@ expect($recorded)->toHaveCount(2); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); expect($followUpBody)->toHaveKey('previous_response_id') ->and($followUpBody['previous_response_id'])->not->toBeEmpty(); $hasToolOutput = collect($followUpBody['input'])->contains( - fn ($item) => ($item['type'] ?? '') === 'function_call_output' + fn ($item): bool => ($item['type'] ?? '') === 'function_call_output' ); expect($hasToolOutput)->toBeTrue('Follow-up should include function_call_output'); }); -test('remote image attachment maps to input image', function () { +test('remote image attachment maps to input image', function (): void { Http::fake(['*' => $this->fakeTextResponse('I see an image')]); $image = new RemoteImage('https://example.com/image.png'); @@ -76,7 +76,7 @@ provider: 'xai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMsg = collect($body['input'])->firstWhere('role', 'user'); @@ -87,7 +87,7 @@ }); }); -test('base64 image attachment maps to data uri', function () { +test('base64 image attachment maps to data uri', function (): void { Http::fake(['*' => $this->fakeTextResponse('I see an image')]); $image = new Base64Image(base64_encode('fake-image-data'), 'image/png'); @@ -98,18 +98,18 @@ provider: 'xai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMsg = collect($body['input'])->firstWhere('role', 'user'); $imageBlock = collect($userMsg['content'])->firstWhere('type', 'input_image'); return $imageBlock !== null - && str_starts_with($imageBlock['image_url'], 'data:image/png;base64,'); + && str_starts_with((string) $imageBlock['image_url'], 'data:image/png;base64,'); }); }); -test('local image attachment without explicit mime type detects mime from file', function () { +test('local image attachment without explicit mime type detects mime from file', function (): void { Http::fake(['*' => $this->fakeTextResponse('I see an image')]); agent('You are helpful.')->prompt( @@ -118,18 +118,18 @@ provider: 'xai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMsg = collect($body['input'])->firstWhere('role', 'user'); $imageBlock = collect($userMsg['content'])->firstWhere('type', 'input_image'); return $imageBlock !== null - && str_starts_with($imageBlock['image_url'], 'data:image/png;base64,') - && ! str_contains($imageBlock['image_url'], 'data:;base64,'); + && str_starts_with((string) $imageBlock['image_url'], 'data:image/png;base64,') + && ! str_contains((string) $imageBlock['image_url'], 'data:;base64,'); }); }); -test('remote document maps to input file', function () { +test('remote document maps to input file', function (): void { Http::fake(['*' => $this->fakeTextResponse('I see a document')]); $document = new RemoteDocument('https://example.com/report.pdf'); @@ -140,7 +140,7 @@ provider: 'xai', ); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $userMsg = collect($body['input'])->firstWhere('role', 'user'); @@ -151,12 +151,11 @@ }); }); -test('reasoning blocks are interleaved with associated tool calls on assistant replay', function () { +test('reasoning blocks are interleaved with associated tool calls on assistant replay', function (): void { Http::fake(['*' => $this->fakeTextResponse('hi')]); agent( instructions: 'Hi.', - tools: [(new ToolUsingAgent(fixed: true))->tools()[0]], messages: [ new UserMessage('search'), new AssistantMessage('Searching.', collect([ @@ -194,17 +193,18 @@ ])), new UserMessage('thanks'), ], + tools: [(new ToolUsingAgent(fixed: true))->tools()[0]], )->prompt('', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $input = $body['input']; - $rs1Index = collect($input)->search(fn ($i) => ($i['type'] ?? '') === 'reasoning' && ($i['id'] ?? '') === 'rs_1'); - $call1Index = collect($input)->search(fn ($i) => ($i['id'] ?? '') === 'call_1'); - $rs2Index = collect($input)->search(fn ($i) => ($i['type'] ?? '') === 'reasoning' && ($i['id'] ?? '') === 'rs_2'); - $call2Index = collect($input)->search(fn ($i) => ($i['id'] ?? '') === 'call_2'); - $call3Index = collect($input)->search(fn ($i) => ($i['id'] ?? '') === 'call_3'); + $rs1Index = collect($input)->search(fn ($i): bool => ($i['type'] ?? '') === 'reasoning' && ($i['id'] ?? '') === 'rs_1'); + $call1Index = collect($input)->search(fn ($i): bool => ($i['id'] ?? '') === 'call_1'); + $rs2Index = collect($input)->search(fn ($i): bool => ($i['type'] ?? '') === 'reasoning' && ($i['id'] ?? '') === 'rs_2'); + $call2Index = collect($input)->search(fn ($i): bool => ($i['id'] ?? '') === 'call_2'); + $call3Index = collect($input)->search(fn ($i): bool => ($i['id'] ?? '') === 'call_3'); return $rs1Index !== false && $call1Index !== false @@ -216,17 +216,17 @@ }); }); -test('system instructions are in input array', function () { +test('system instructions are in input array', function (): void { Http::fake(['*' => $this->fakeTextResponse()]); (new AssistantAgent)->prompt('Hi', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $systemMsg = collect($body['input'])->firstWhere('role', 'system'); return $systemMsg !== null - && str_contains($systemMsg['content'], 'helpful assistant'); + && str_contains((string) $systemMsg['content'], 'helpful assistant'); }); }); diff --git a/tests/Feature/Providers/Xai/ProviderOptionsTest.php b/tests/Feature/Providers/Xai/ProviderOptionsTest.php index abfe63535..481135d01 100644 --- a/tests/Feature/Providers/Xai/ProviderOptionsTest.php +++ b/tests/Feature/Providers/Xai/ProviderOptionsTest.php @@ -8,21 +8,21 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.xai' => [ ...config('ai.providers.xai'), 'key' => 'test-key', ]]); }); -test('provider options are included in xai request body', function () { +test('provider options are included in xai request body', function (): void { Http::fake([ '*' => fakeXaiProviderOptionsResponse('Hello'), ]); (new ProviderOptionsAgent)->prompt('Hello', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'frequency_penalty') === 0.5 @@ -30,14 +30,14 @@ }); }); -test('request body does not contain provider options when agent does not implement interface', function () { +test('request body does not contain provider options when agent does not implement interface', function (): void { Http::fake([ '*' => fakeXaiProviderOptionsResponse('Hello'), ]); agent()->prompt('Hello', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('frequency_penalty', $body) @@ -45,7 +45,7 @@ }); }); -test('provider options are persisted in tool call follow up requests', function () { +test('provider options are persisted in tool call follow up requests', function (): void { Http::fake([ '*' => Http::sequence([ fakeXaiProviderOptionsToolCallResponse(), @@ -55,11 +55,11 @@ (new ProviderOptionsWithToolsAgent)->prompt('Give me a number', provider: 'xai'); - $requests = Http::recorded(fn (Request $r) => true); + $requests = Http::recorded(fn (Request $r): true => true); expect(count($requests))->toBeGreaterThanOrEqual(2); - $followUpBody = json_decode($requests[1][0]->body(), true); + $followUpBody = json_decode((string) $requests[1][0]->body(), true); expect(data_get($followUpBody, 'frequency_penalty'))->toBe(0.5); }); diff --git a/tests/Feature/Providers/Xai/RequestMappingTest.php b/tests/Feature/Providers/Xai/RequestMappingTest.php index 2dc1483ea..ed892083d 100644 --- a/tests/Feature/Providers/Xai/RequestMappingTest.php +++ b/tests/Feature/Providers/Xai/RequestMappingTest.php @@ -12,48 +12,48 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.xai' => [ ...config('ai.providers.xai'), 'key' => 'test-key', ]]); }); -test('request includes model and input', function () { +test('request includes model and input', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('Hello')]); agent()->prompt('Hi there', provider: 'xai', model: 'grok-4-1-fast-reasoning'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['model'] === 'grok-4-1-fast-reasoning' && is_array($body['input']) - && collect($body['input'])->contains(fn ($m) => $m['role'] === 'user' - && collect($m['content'])->contains(fn ($c) => ($c['type'] ?? '') === 'input_text' && $c['text'] === 'Hi there')); + && collect($body['input'])->contains(fn ($m): bool => $m['role'] === 'user' + && collect($m['content'])->contains(fn ($c): bool => ($c['type'] ?? '') === 'input_text' && $c['text'] === 'Hi there')); }); }); -test('system instructions are sent as system message', function () { +test('system instructions are sent as system message', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('Hello')]); (new AssistantAgent)->prompt('Hello', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $systemMsg = collect($body['input'])->firstWhere('role', 'system'); return $systemMsg !== null - && str_contains($systemMsg['content'], 'helpful assistant'); + && str_contains((string) $systemMsg['content'], 'helpful assistant'); }); }); -test('temperature and max tokens are included when set via attributes', function () { +test('temperature and max tokens are included when set via attributes', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('Hello')]); (new AttributeAgent)->prompt('Hello', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return data_get($body, 'temperature') === 0.7 @@ -61,12 +61,12 @@ }); }); -test('temperature and max tokens are excluded when not set', function () { +test('temperature and max tokens are excluded when not set', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('Hello')]); agent()->prompt('Hello', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('temperature', $body) @@ -74,26 +74,26 @@ }); }); -test('tools include tool choice auto', function () { +test('tools include tool choice auto', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('42')]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a number', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['tool_choice'] === 'auto' && is_array($body['tools']) - && count($body['tools']) > 0; + && $body['tools'] !== []; }); }); -test('request without tools excludes tool fields', function () { +test('request without tools excludes tool fields', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('Hello')]); agent()->prompt('Hello', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('tools', $body) @@ -101,49 +101,47 @@ }); }); -test('required tool choice forces the model to call a tool', function () { +test('required tool choice forces the model to call a tool', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('42')]); (new ToolChoiceAgent('required'))->prompt('Give me a number', provider: 'xai'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'required'); }); -test('required tool choice can be set via attribute', function () { +test('required tool choice can be set via attribute', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('42')]); (new AttributeToolChoiceAgent)->prompt('Give me a number', provider: 'xai'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'required'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'required'); }); -test('named tool choice forces a specific function', function () { +test('named tool choice forces a specific function', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('42')]); (new ToolChoiceAgent(['tool' => 'custom_named_tool']))->prompt('Give me a number', provider: 'xai'); - Http::assertSent(function (Request $request) { - return json_decode($request->body(), true)['tool_choice'] === [ - 'type' => 'function', - 'name' => 'custom_named_tool', - ]; - }); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === [ + 'type' => 'function', + 'name' => 'custom_named_tool', + ]); }); -test('none tool choice prevents tool calls', function () { +test('none tool choice prevents tool calls', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('Sure')]); (new ToolChoiceAgent('none'))->prompt('Just talk', provider: 'xai'); - Http::assertSent(fn (Request $request) => json_decode($request->body(), true)['tool_choice'] === 'none'); + Http::assertSent(fn (Request $request): bool => json_decode($request->body(), true)['tool_choice'] === 'none'); }); -test('structured output includes json schema text format', function () { +test('structured output includes json schema text format', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('{"symbol": "Au"}')]); (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $format = data_get($body, 'text.format'); @@ -154,19 +152,19 @@ }); }); -test('request without schema excludes text format', function () { +test('request without schema excludes text format', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('Hello')]); agent()->prompt('Hello', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return ! array_key_exists('text', $body); }); }); -test('streaming request includes stream flag', function () { +test('streaming request includes stream flag', function (): void { $sseData = "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\",\"model\":\"grok-4-1-fast-reasoning\"}}\n\n" ."data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hi\"}\n\n" ."data: {\"type\":\"response.output_text.done\"}\n\n" @@ -180,24 +178,22 @@ // } - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); return $body['stream'] === true; }); }); -test('request sends bearer token authorization', function () { +test('request sends bearer token authorization', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('Hello')]); agent()->prompt('Hello', provider: 'xai'); - Http::assertSent(function (Request $request) { - return $request->hasHeader('Authorization', 'Bearer test-key'); - }); + Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer test-key')); }); -test('response text is correctly parsed', function () { +test('response text is correctly parsed', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('Laravel is great')]); $response = agent()->prompt('Tell me about Laravel', provider: 'xai'); @@ -206,7 +202,7 @@ ->and($response->meta->provider)->toBe('xai'); }); -test('response usage is correctly parsed', function () { +test('response usage is correctly parsed', function (): void { Http::fake(['*' => Http::response([ 'id' => 'resp_123', 'object' => 'response', @@ -234,7 +230,7 @@ ->and($response->usage->completionTokens)->toBe(5); }); -test('structured response is correctly parsed', function () { +test('structured response is correctly parsed', function (): void { Http::fake(['*' => fakeXaiRequestMappingResponse('{"symbol": "Au"}')]); $response = (new StructuredAgent)->prompt('What is the symbol for Gold?', provider: 'xai'); @@ -242,7 +238,7 @@ expect($response->structured['symbol'])->toBe('Au'); }); -test('citations preserve every annotation with span indices', function () { +test('citations preserve every annotation with span indices', function (): void { Http::fake(['*' => Http::response([ 'id' => 'resp_123', 'object' => 'response', @@ -298,7 +294,7 @@ ->and($response->meta->citations[2]->startIndex)->toBe(26); }); -test('citations omit span indices when not provided by the api', function () { +test('citations omit span indices when not provided by the api', function (): void { Http::fake(['*' => Http::response([ 'id' => 'resp_123', 'object' => 'response', diff --git a/tests/Feature/Providers/Xai/StreamingTest.php b/tests/Feature/Providers/Xai/StreamingTest.php index 2e1df9b60..7e44e9b92 100644 --- a/tests/Feature/Providers/Xai/StreamingTest.php +++ b/tests/Feature/Providers/Xai/StreamingTest.php @@ -12,14 +12,14 @@ use Laravel\Ai\Streaming\Events\ToolResult as ToolResultEvent; use Tests\Fixtures\Agents\ProviderOptionsWithToolsAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.xai' => [ ...config('ai.providers.xai'), 'key' => 'test-key', ]]); }); -test('streaming emits text events', function () { +test('streaming emits text events', function (): void { Http::fake([ '*' => Http::response( body: $this->ssePayload([ @@ -44,7 +44,7 @@ ->and($events[5])->toBeInstanceOf(StreamEnd::class); }); -test('streaming handles tool calls', function () { +test('streaming handles tool calls', function (): void { Http::fake([ '*' => Http::sequence([ Http::response( @@ -73,15 +73,15 @@ $events = $this->collectStreamEvents(agent: new ProviderOptionsWithToolsAgent); - $toolCallEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolCallEvent)); - $toolResultEvents = array_values(array_filter($events, fn ($e) => $e instanceof ToolResultEvent)); + $toolCallEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolCallEvent)); + $toolResultEvents = array_values(array_filter($events, fn ($e): bool => $e instanceof ToolResultEvent)); expect($toolCallEvents)->not->toBeEmpty() ->and($toolCallEvents[0]->toolCall->name)->toBe('FixedNumberGenerator') ->and($toolResultEvents)->not->toBeEmpty(); }); -test('streaming captures usage', function () { +test('streaming captures usage', function (): void { Http::fake([ '*' => Http::response( body: $this->ssePayload([ @@ -97,7 +97,7 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->usage->promptTokens)->toBe(8); // 10 - 2 cached expect($streamEnd->usage->completionTokens)->toBe(5); @@ -105,7 +105,7 @@ ->and($streamEnd->usage->reasoningTokens)->toBe(3); }); -test('streaming error event stops stream', function () { +test('streaming error event stops stream', function (): void { Http::fake([ '*' => Http::response( body: $this->ssePayload([ @@ -124,7 +124,7 @@ ->and($events[0]->message)->toBe('Internal server error'); }); -test('streaming finish reason maps correctly', function (string $status, string $type, $expected) { +test('streaming finish reason maps correctly', function (string $status, string $type, $expected): void { Http::fake([ '*' => Http::response( body: $this->ssePayload([ @@ -140,7 +140,7 @@ $events = $this->collectStreamEvents(); - $streamEnd = array_values(array_filter($events, fn ($e) => $e instanceof StreamEnd))[0]; + $streamEnd = array_values(array_filter($events, fn ($e): bool => $e instanceof StreamEnd))[0]; expect($streamEnd->reason)->toBe($expected->value); })->with([ diff --git a/tests/Feature/Providers/Xai/ToolCallLoopTest.php b/tests/Feature/Providers/Xai/ToolCallLoopTest.php index f53140f3a..6604a6386 100644 --- a/tests/Feature/Providers/Xai/ToolCallLoopTest.php +++ b/tests/Feature/Providers/Xai/ToolCallLoopTest.php @@ -3,14 +3,14 @@ use Illuminate\Support\Facades\Http; use Tests\Fixtures\Agents\ToolUsingAgent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.xai' => [ ...config('ai.providers.xai'), 'key' => 'test-key', ]]); }); -test('tool calls trigger follow up request', function () { +test('tool calls trigger follow up request', function (): void { Http::fake([ '*' => Http::sequence([ $this->fakeToolCallResponse(), @@ -27,7 +27,7 @@ expect($recorded)->toHaveCount(2); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); expect($followUpBody)->toHaveKey('previous_response_id'); @@ -42,7 +42,7 @@ expect($hasToolOutput)->toBeTrue('Follow-up request should include function_call_output'); }); -test('max steps limits tool call depth', function () { +test('max steps limits tool call depth', function (): void { Http::fake([ '*' => Http::sequence([ $this->fakeToolCallResponse('FixedNumberGenerator', 'call_'.uniqid()), @@ -62,7 +62,7 @@ expect(count($recorded))->toBeLessThanOrEqual(3); }); -test('follow up request preserves tools', function () { +test('follow up request preserves tools', function (): void { Http::fake([ '*' => Http::sequence([ $this->fakeToolCallResponse(), @@ -77,7 +77,7 @@ $recorded = Http::recorded(); - $followUpBody = json_decode($recorded[1][0]->body(), true); + $followUpBody = json_decode((string) $recorded[1][0]->body(), true); expect($followUpBody)->toHaveKey('tools') ->and($followUpBody['tools'])->not->toBeEmpty(); diff --git a/tests/Feature/Providers/Xai/ToolMappingTest.php b/tests/Feature/Providers/Xai/ToolMappingTest.php index 7856245ab..6d1e5b23b 100644 --- a/tests/Feature/Providers/Xai/ToolMappingTest.php +++ b/tests/Feature/Providers/Xai/ToolMappingTest.php @@ -9,21 +9,21 @@ use function Laravel\Ai\agent; -beforeEach(function () { +beforeEach(function (): void { config(['ai.providers.xai' => [ ...config('ai.providers.xai'), 'key' => 'test-key', ]]); }); -test('tool with parameters includes correct schema', function () { +test('tool with parameters includes correct schema', function (): void { Http::fake([ '*' => fakeXaiToolMappingResponse('42'), ]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a random number', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); @@ -36,14 +36,14 @@ }); }); -test('tool with empty schema includes parameters', function () { +test('tool with empty schema includes parameters', function (): void { Http::fake([ '*' => fakeXaiToolMappingResponse('72019'), ]); agent(tools: [new FixedNumberGenerator])->prompt('Give me a random number', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); @@ -55,12 +55,12 @@ }); }); -test('tool with a name() method emits the declared name', function () { +test('tool with a name() method emits the declared name', function (): void { Http::fake(['*' => fakeXaiToolMappingResponse('ok')]); agent(tools: [new NamedTool('my_custom_tool')])->prompt('Hi', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $names = collect(data_get($body, 'tools'))->pluck('name')->all(); @@ -68,14 +68,14 @@ }); }); -test('tool parameters are not wrapped in schema definition', function () { +test('tool parameters are not wrapped in schema definition', function (): void { Http::fake([ '*' => fakeXaiToolMappingResponse('done'), ]); agent(tools: [new RandomNumberGenerator])->prompt('Give me a random number', provider: 'xai'); - Http::assertSent(function (Request $request) { + Http::assertSent(function (Request $request): bool { $body = json_decode($request->body(), true); $tool = collect(data_get($body, 'tools'))->firstWhere('type', 'function'); diff --git a/tests/Feature/RerankingFakeTest.php b/tests/Feature/RerankingFakeTest.php index 8f953fdf3..cc4809812 100644 --- a/tests/Feature/RerankingFakeTest.php +++ b/tests/Feature/RerankingFakeTest.php @@ -6,37 +6,37 @@ use Laravel\Ai\Reranking; use Laravel\Ai\Responses\Data\RankedDocument; -test('rerank rejects empty document list', function () { +test('rerank rejects empty document list', function (): void { Reranking::fake(); Reranking::of([])->rerank('What is Laravel?'); })->throws(InvalidArgumentException::class, 'At least one document is required to rerank.'); -test('rerank rejects empty collection of documents', function () { +test('rerank rejects empty collection of documents', function (): void { Reranking::fake(); Reranking::of(collect([]))->rerank('What is Laravel?'); })->throws(InvalidArgumentException::class, 'At least one document is required to rerank.'); -test('rerank rejects blank document strings', function () { +test('rerank rejects blank document strings', function (): void { Reranking::fake(); Reranking::of([''])->rerank('What is Laravel?'); })->throws(InvalidArgumentException::class, 'Each document to rerank must be a non-blank string (index 0).'); -test('rerank rejects whitespace-only document strings', function () { +test('rerank rejects whitespace-only document strings', function (): void { Reranking::fake(); Reranking::of([" \t\n"])->rerank('What is Laravel?'); })->throws(InvalidArgumentException::class, 'Each document to rerank must be a non-blank string (index 0).'); -test('rerank rejects non-string documents', function () { +test('rerank rejects non-string documents', function (): void { Reranking::fake(); Reranking::of([123])->rerank('What is Laravel?'); })->throws(InvalidArgumentException::class, 'Each document to rerank must be a non-blank string (index 0).'); -test('can fake reranking', function () { +test('can fake reranking', function (): void { Reranking::fake(); $response = Reranking::of([ @@ -49,7 +49,7 @@ ->and($response->first())->toBeInstanceOf(RankedDocument::class); }); -test('can fake reranking with limit', function () { +test('can fake reranking with limit', function (): void { Reranking::fake(); $response = Reranking::of([ @@ -63,7 +63,7 @@ expect($response)->toHaveCount(3); }); -test('can fake reranking with custom response', function () { +test('can fake reranking with custom response', function (): void { Reranking::fake([ [ new RankedDocument(index: 0, document: 'First doc', score: 0.95), @@ -78,14 +78,12 @@ ->and($response->first()->document)->toEqual('First doc'); }); -test('can fake reranking with closure', function () { - Reranking::fake(function (RerankingPrompt $prompt) { - return (new Collection($prompt->documents))->map(fn ($doc, $index) => new RankedDocument( - index: $index, - document: $doc, - score: 1.0 - ($index * 0.1), - ))->all(); - }); +test('can fake reranking with closure', function (): void { + Reranking::fake(fn (RerankingPrompt $prompt) => (new Collection($prompt->documents))->map(fn ($doc, $index): RankedDocument => new RankedDocument( + index: $index, + document: $doc, + score: 1.0 - ($index * 0.1), + ))->all()); $response = Reranking::of(['Doc A', 'Doc B', 'Doc C'])->rerank('test query'); @@ -94,47 +92,39 @@ ->and($response->first()->document)->toEqual('Doc A'); }); -test('can assert reranked', function () { +test('can assert reranked', function (): void { Reranking::fake(); Reranking::of(['Laravel is great', 'PHP is cool'])->rerank('Laravel'); - Reranking::assertReranked(function (RerankingPrompt $prompt) { - return $prompt->contains('Laravel'); - }); + Reranking::assertReranked(fn (RerankingPrompt $prompt): bool => $prompt->contains('Laravel')); - Reranking::assertReranked(function (RerankingPrompt $prompt) { - return $prompt->documentsContain('Laravel is great'); - }); + Reranking::assertReranked(fn (RerankingPrompt $prompt): bool => $prompt->documentsContain('Laravel is great')); }); -test('can assert not reranked', function () { +test('can assert not reranked', function (): void { Reranking::fake(); Reranking::of(['Laravel is great'])->rerank('Laravel'); - Reranking::assertNotReranked(function (RerankingPrompt $prompt) { - return $prompt->contains('Python'); - }); + Reranking::assertNotReranked(fn (RerankingPrompt $prompt): bool => $prompt->contains('Python')); - Reranking::assertNotReranked(function (RerankingPrompt $prompt) { - return $prompt->documentsContain('Python is great'); - }); + Reranking::assertNotReranked(fn (RerankingPrompt $prompt): bool => $prompt->documentsContain('Python is great')); }); -test('can assert nothing reranked', function () { +test('can assert nothing reranked', function (): void { Reranking::fake(); Reranking::assertNothingReranked(); }); -test('can prevent stray rerankings', function () { +test('can prevent stray rerankings', function (): void { Reranking::fake()->preventStrayRerankings(); Reranking::of(['Doc 1', 'Doc 2'])->rerank('query'); })->throws(RuntimeException::class); -test('fake reranking shuffles documents', function () { +test('fake reranking shuffles documents', function (): void { Reranking::fake(); $documents = ['Doc A', 'Doc B', 'Doc C', 'Doc D', 'Doc E']; @@ -149,7 +139,7 @@ } }); -test('can iterate over response', function () { +test('can iterate over response', function (): void { Reranking::fake(); $response = Reranking::of(['Doc A', 'Doc B'])->rerank('query'); @@ -163,7 +153,7 @@ expect($documents)->toHaveCount(2); }); -test('can get documents in reranked order', function () { +test('can get documents in reranked order', function (): void { Reranking::fake([ [ new RankedDocument(index: 1, document: 'Second', score: 0.9), @@ -176,22 +166,18 @@ expect($response->documents()->all())->toEqual(['Second', 'First']); }); -test('rerank accepts ai provider enum', function () { +test('rerank accepts ai provider enum', function (): void { Reranking::fake(); Reranking::of(['Laravel is great', 'PHP is cool'])->rerank('Laravel', provider: Lab::Cohere); - Reranking::assertReranked(function (RerankingPrompt $prompt) { - return $prompt->contains('Laravel'); - }); + Reranking::assertReranked(fn (RerankingPrompt $prompt): bool => $prompt->contains('Laravel')); }); -test('prompt records limit', function () { +test('prompt records limit', function (): void { Reranking::fake(); Reranking::of(['Doc A', 'Doc B', 'Doc C'])->limit(2)->rerank('query'); - Reranking::assertReranked(function (RerankingPrompt $prompt) { - return $prompt->limit === 2 && $prompt->count() === 3; - }); + Reranking::assertReranked(fn (RerankingPrompt $prompt): bool => $prompt->limit === 2 && $prompt->count() === 3); }); diff --git a/tests/Feature/SimilaritySearchTest.php b/tests/Feature/SimilaritySearchTest.php index d9054b194..358f53e51 100644 --- a/tests/Feature/SimilaritySearchTest.php +++ b/tests/Feature/SimilaritySearchTest.php @@ -4,7 +4,7 @@ use Laravel\Ai\Tools\Request; use Laravel\Ai\Tools\SimilaritySearch; -test('search results are returned', function () { +test('search results are returned', function (): void { $data = [ [ 'id' => 1, @@ -16,9 +16,7 @@ ], ]; - $search = new SimilaritySearch(using: function (string $query) use ($data) { - return $data; - }); + $search = new SimilaritySearch(using: fn (string $query): array => $data); $results = $search->handle(new Request([ 'query' => 'Test query', @@ -27,15 +25,15 @@ expect(str_contains($results, json_encode($data, JSON_PRETTY_PRINT)))->toBeTrue(); }); -test('using model rejects blank model class', function () { +test('using model rejects blank model class', function (): void { SimilaritySearch::usingModel('', 'embedding'); })->throws(InvalidArgumentException::class, 'A model class name is required for similarity search.'); -test('using model rejects blank column name', function () { +test('using model rejects blank column name', function (): void { SimilaritySearch::usingModel(FakeVectorModel::class, ' '); })->throws(InvalidArgumentException::class, 'A vector column name is required for similarity search.'); -test('using model creates similarity search', function () { +test('using model creates similarity search', function (): void { $search = SimilaritySearch::usingModel( FakeVectorModel::class, 'embedding', @@ -51,7 +49,7 @@ ->toContain('Second document'); }); -test('using model applies custom query closure', function () { +test('using model applies custom query closure', function (): void { $search = SimilaritySearch::usingModel( FakeVectorModel::class, 'embedding', @@ -66,7 +64,7 @@ expect($results)->toContain('Relevant results found.'); }); -test('using model excludes embedding column from results', function () { +test('using model excludes embedding column from results', function (): void { $search = SimilaritySearch::usingModel( FakeVectorModel::class, 'embedding', diff --git a/tests/Feature/Storage/DatabaseConversationStoreTest.php b/tests/Feature/Storage/DatabaseConversationStoreTest.php index 110ca7e00..ce26f8eef 100644 --- a/tests/Feature/Storage/DatabaseConversationStoreTest.php +++ b/tests/Feature/Storage/DatabaseConversationStoreTest.php @@ -1,6 +1,7 @@ storeConversation(1, 'Hello'); @@ -30,7 +31,7 @@ expect(DB::table('agent_conversations')->where('id', $conversationId)->where('title', 'Hello')->exists())->toBeTrue(); }); -test('it writes to overridden table names from config', function () { +test('it writes to overridden table names from config', function (): void { Config::set('ai.conversations.tables.conversations', 'custom_conversations'); Config::set('ai.conversations.tables.messages', 'custom_conversation_messages'); @@ -43,7 +44,7 @@ ->and(DB::table('agent_conversations')->where('id', $conversationId)->exists())->toBeFalse(); }); -test('it routes queries through the configured connection', function () { +test('it routes queries through the configured connection', function (): void { Config::set('database.connections.secondary', [ 'driver' => 'sqlite', 'database' => ':memory:', @@ -60,7 +61,7 @@ ->and(DB::table('agent_conversations')->where('id', $conversationId)->exists())->toBeFalse(); }); -test('it persists tool calls and results from a remembered agent prompt', function () { +test('it persists tool calls and results from a remembered agent prompt', function (): void { Http::fake([ '*' => Http::sequence([ Http::response([ @@ -105,11 +106,11 @@ ->where('role', 'assistant') ->first(); - expect(json_decode($record->tool_calls, true))->toBeList() - ->and(json_decode($record->tool_results, true))->toBeList(); + expect(json_decode((string) $record->tool_calls, true))->toBeList() + ->and(json_decode((string) $record->tool_results, true))->toBeList(); }); -test('it stores sparse keyed tool calls and results as JSON arrays', function () { +test('it stores sparse keyed tool calls and results as JSON arrays', function (): void { $store = new DatabaseConversationStore; $conversationId = $store->storeConversation(1, 'Tool conversation'); @@ -137,11 +138,11 @@ ->where('role', 'assistant') ->first(); - expect(array_is_list(json_decode($record->tool_calls, true)))->toBeTrue() - ->and(array_is_list(json_decode($record->tool_results, true)))->toBeTrue(); + expect(array_is_list(json_decode((string) $record->tool_calls, true)))->toBeTrue() + ->and(array_is_list(json_decode((string) $record->tool_results, true)))->toBeTrue(); }); -test('it reloads legacy sparse keyed tool calls and results as lists', function () { +test('it reloads legacy sparse keyed tool calls and results as lists', function (): void { $store = new DatabaseConversationStore; $conversationId = $store->storeConversation(1, 'Tool conversation'); @@ -178,7 +179,7 @@ ->and($messages[2]->content)->toBe('The order has shipped.'); }); -test('it replays stored tool conversations before the final assistant response', function () { +test('it replays stored tool conversations before the final assistant response', function (): void { $store = new DatabaseConversationStore; $conversationId = $store->storeConversation(1, 'Tool conversation'); @@ -217,7 +218,7 @@ ->and($messages[2]->toolCalls)->toBeEmpty(); }); -test('it drops resultless tool calls and replays only the final assistant text', function () { +test('it drops resultless tool calls and replays only the final assistant text', function (): void { $store = new DatabaseConversationStore; $conversationId = $store->storeConversation(1, 'Tool conversation'); @@ -247,7 +248,7 @@ ->and($messages[0]->toolCalls)->toBeEmpty(); }); -test('it drops resultless tool calls with no final text entirely', function () { +test('it drops resultless tool calls with no final text entirely', function (): void { $store = new DatabaseConversationStore; $conversationId = $store->storeConversation(1, 'Tool conversation'); @@ -274,7 +275,7 @@ expect($messages)->toBeEmpty(); }); -test('it rehydrates reasoning encrypted content on stored tool calls', function () { +test('it rehydrates reasoning encrypted content on stored tool calls', function (): void { $store = new DatabaseConversationStore; $conversationId = $store->storeConversation(1, 'Reasoning conversation'); @@ -312,7 +313,7 @@ ->reasoningEncryptedContent->toBe('enc-blob-1'); }); -test('it rehydrates legacy tool calls that predate reasoning encrypted content', function () { +test('it rehydrates legacy tool calls that predate reasoning encrypted content', function (): void { $store = new DatabaseConversationStore; $conversationId = $store->storeConversation(1, 'Legacy conversation'); @@ -344,7 +345,7 @@ ->reasoningEncryptedContent->toBeNull(); }); -test('user messages with stored attachments are rehydrated as UserMessage', function () { +test('user messages with stored attachments are rehydrated as UserMessage', function (): void { $store = new DatabaseConversationStore; $conversationId = $store->storeConversation(1, 'Attachment conversation'); @@ -376,7 +377,7 @@ ->and($messages[0]->attachments->first()->url)->toBe('https://example.com/photo.jpg'); }); -test('user messages with multiple attachment types are all rehydrated', function () { +test('user messages with multiple attachment types are all rehydrated', function (): void { $store = new DatabaseConversationStore; $conversationId = $store->storeConversation(1, 'Multi-attachment conversation'); @@ -408,7 +409,7 @@ ->and($messages[0]->attachments[1]->path)->toBe('docs/report.pdf'); }); -test('user messages with no attachments are returned as plain Message', function () { +test('user messages with no attachments are returned as plain Message', function (): void { $store = new DatabaseConversationStore; $conversationId = $store->storeConversation(1, 'Plain conversation'); @@ -434,7 +435,7 @@ ->and($messages[0])->not->toBeInstanceOf(UserMessage::class); }); -test('malformed stored attachment JSON fails loudly', function () { +test('malformed stored attachment JSON fails loudly', function (): void { $store = new DatabaseConversationStore; $conversationId = $store->storeConversation(1, 'Malformed attachment conversation'); @@ -454,11 +455,11 @@ 'updated_at' => now(), ]); - expect(fn () => $store->getLatestConversationMessages($conversationId, 10)) + expect(fn (): Collection => $store->getLatestConversationMessages($conversationId, 10)) ->toThrow(InvalidArgumentException::class, 'Stored conversation attachments must be a JSON array.'); }); -test('malformed known stored attachments fail loudly', function () { +test('malformed known stored attachments fail loudly', function (): void { $store = new DatabaseConversationStore; $conversationId = $store->storeConversation(1, 'Malformed attachment conversation'); @@ -480,7 +481,7 @@ 'updated_at' => now(), ]); - expect(fn () => $store->getLatestConversationMessages($conversationId, 10)) + expect(fn (): Collection => $store->getLatestConversationMessages($conversationId, 10)) ->toThrow(InvalidArgumentException::class, 'Cannot reconstruct [remote-image] attachment because [url] is missing or invalid.'); }); @@ -491,14 +492,14 @@ function createConversationSchema(?string $connection = null): void $conversationsTable = config('ai.conversations.tables.conversations', 'agent_conversations'); $messagesTable = config('ai.conversations.tables.messages', 'agent_conversation_messages'); - $schema->create($conversationsTable, function (Blueprint $table) { + $schema->create($conversationsTable, function (Blueprint $table): void { $table->string('id', 36)->primary(); $table->foreignId('user_id')->nullable(); $table->string('title'); $table->timestamps(); }); - $schema->create($messagesTable, function (Blueprint $table) { + $schema->create($messagesTable, function (Blueprint $table): void { $table->string('id', 36)->primary(); $table->string('conversation_id', 36)->index(); $table->foreignId('user_id')->nullable(); diff --git a/tests/Feature/StoreFakeTest.php b/tests/Feature/StoreFakeTest.php index a599c0c4d..1cc944c05 100644 --- a/tests/Feature/StoreFakeTest.php +++ b/tests/Feature/StoreFakeTest.php @@ -9,11 +9,11 @@ use function Illuminate\Support\days; -describe('store operations', function () { - test('stores can be faked', function () { +describe('store operations', function (): void { + test('stores can be faked', function (): void { Stores::fake([ 'first-store', - fn ($storeId) => "store-{$storeId}", + fn ($storeId): string => "store-{$storeId}", 'Custom Store', ]); @@ -27,7 +27,7 @@ expect($response)->id->toEqual('vs_3')->name->toEqual('Custom Store'); }); - test('stores can be faked with no predefined responses', function () { + test('stores can be faked with no predefined responses', function (): void { Stores::fake(); $response = Stores::get('vs_1'); @@ -35,23 +35,23 @@ expect($response)->id->toEqual('vs_1')->name->toEqual('fake-store'); }); - test('stores can be faked with a closure', function () { - Stores::fake(fn ($storeId) => "name-for-{$storeId}"); + test('stores can be faked with a closure', function (): void { + Stores::fake(fn ($storeId): string => "name-for-{$storeId}"); $response = Stores::get('vs_1'); expect($response)->id->toEqual('vs_1')->name->toEqual('name-for-vs_1'); }); - test('stores can prevent stray operations', function () { + test('stores can prevent stray operations', function (): void { Stores::fake()->preventStrayOperations(); Stores::get('vs_1'); })->throws(RuntimeException::class); }); -describe('store assertions', function () { - test('can assert store was created by name', function () { +describe('store assertions', function (): void { + test('can assert store was created by name', function (): void { Stores::fake(); Stores::create('My Vector Store'); @@ -60,7 +60,7 @@ Stores::assertNotCreated('Other Store'); }); - test('can assert store was created with closure', function () { + test('can assert store was created with closure', function (): void { Stores::fake(); Stores::create( @@ -69,46 +69,46 @@ expiresWhenIdleFor: days(7), ); - Stores::assertCreated(fn (string $name) => $name === 'My Vector Store'); - Stores::assertCreated(fn (string $name, ?string $description) => $description === 'A test store'); + Stores::assertCreated(fn (string $name): bool => $name === 'My Vector Store'); + Stores::assertCreated(fn (string $name, ?string $description): bool => $description === 'A test store'); Stores::assertCreated(fn ( string $name, ?string $description, Collection $fileIds, ?DateInterval $expiresWhenIdleFor - ) => $expiresWhenIdleFor !== null); + ): bool => $expiresWhenIdleFor instanceof DateInterval); - Stores::assertNotCreated(fn (string $name) => $name === 'Other Store'); + Stores::assertNotCreated(fn (string $name): bool => $name === 'Other Store'); }); - test('can assert no stores were created', function () { + test('can assert no stores were created', function (): void { Stores::fake(); Stores::assertNothingCreated(); }); - test('can assert store was deleted', function () { + test('can assert store was deleted', function (): void { Stores::fake(); Stores::delete('vs_123'); Stores::assertDeleted('vs_123'); - Stores::assertDeleted(fn (string $id) => $id === 'vs_123'); + Stores::assertDeleted(fn (string $id): bool => $id === 'vs_123'); Stores::assertNotDeleted('vs_456'); - Stores::assertNotDeleted(fn (string $id) => $id === 'vs_456'); + Stores::assertNotDeleted(fn (string $id): bool => $id === 'vs_456'); }); - test('can assert no stores were deleted', function () { + test('can assert no stores were deleted', function (): void { Stores::fake(); Stores::assertNothingDeleted(); }); }); -describe('file operations', function () { - test('can add file to store with provider id', function () { +describe('file operations', function (): void { + test('can add file to store with provider id', function (): void { Stores::fake(); $store = Stores::create('My Store'); @@ -119,7 +119,7 @@ ->and($searchable->fileId())->toEqual('file_123'); }); - test('can remove file from store with provider id', function () { + test('can remove file from store with provider id', function (): void { Stores::fake(); $result = Stores::create('My Store')->remove(new ProviderDocument('file_123')); @@ -127,7 +127,7 @@ expect($result)->toBeTrue(); }); - test('can remove file from store with string id', function () { + test('can remove file from store with string id', function (): void { Stores::fake(); $result = Stores::create('My Store')->remove('file_123'); @@ -135,7 +135,7 @@ expect($result)->toBeTrue(); }); - test('can add storable file to store', function () { + test('can add storable file to store', function (): void { Stores::fake(); $response = Stores::create('My Store') @@ -144,13 +144,13 @@ expect($response)->not->toBeEmpty(); Files::assertStored( - fn (StorableFile $file) => $file->content() === 'Hello, world!' + fn (StorableFile $file): bool => $file->content() === 'Hello, world!' ); }); }); -describe('file assertions', function () { - test('can assert file added to store', function () { +describe('file assertions', function (): void { + test('can assert file added to store', function (): void { Stores::fake(); $store = Stores::create('My Store'); @@ -159,7 +159,7 @@ $store->add($file); // Using closure receives the original file... - $store->assertAdded(fn ($f) => $f instanceof ProviderDocument && $f->id() === $file->id()); + $store->assertAdded(fn ($f): bool => $f instanceof ProviderDocument && $f->id() === $file->id()); // Using exact IDs... $store->assertAdded($file->id()); @@ -168,7 +168,7 @@ $store->assertAdded('test.txt'); }); - test('can assert file added to store with storable file', function () { + test('can assert file added to store with storable file', function (): void { Stores::fake(); $store = Stores::create('My Store'); @@ -176,11 +176,11 @@ $store->add(Document::fromString('Hello, world!', 'text/plain')->as('hello.txt')); // Using closure receives the original StorableFile... - $store->assertAdded(fn (StorableFile $file) => $file->name() === 'hello.txt'); - $store->assertAdded(fn (StorableFile $file) => $file->content() === 'Hello, world!'); + $store->assertAdded(fn (StorableFile $file): bool => $file->name() === 'hello.txt'); + $store->assertAdded(fn (StorableFile $file): bool => $file->content() === 'Hello, world!'); }); - test('can assert file not added to store', function () { + test('can assert file not added to store', function (): void { Stores::fake(); $store = Stores::create('My Store'); @@ -188,10 +188,10 @@ $store->add($file); - $store->assertNotAdded(fn ($f) => $f instanceof ProviderDocument && $f->id() === 'file_456'); + $store->assertNotAdded(fn ($f): bool => $f instanceof ProviderDocument && $f->id() === 'file_456'); }); - test('can assert file removed from store', function () { + test('can assert file removed from store', function (): void { Stores::fake(); $store = Stores::create('My Store'); @@ -200,7 +200,7 @@ $store->remove($fileId); // Using closure... - $store->assertRemoved(fn ($fId) => $fId === $fileId); + $store->assertRemoved(fn ($fId): bool => $fId === $fileId); // Using exact IDs... $store->assertRemoved($fileId); @@ -209,13 +209,13 @@ $store->assertRemoved('test.txt'); }); - test('can assert file not removed from store', function () { + test('can assert file not removed from store', function (): void { Stores::fake(); $store = Stores::create('My Store'); $store->remove('file_123'); - $store->assertNotRemoved(fn ($fileId) => $fileId === 'file_456'); + $store->assertNotRemoved(fn ($fileId): bool => $fileId === 'file_456'); }); }); diff --git a/tests/Feature/SubAgentTest.php b/tests/Feature/SubAgentTest.php index 134e994ad..f1ab5a634 100644 --- a/tests/Feature/SubAgentTest.php +++ b/tests/Feature/SubAgentTest.php @@ -10,7 +10,7 @@ use Tests\Fixtures\Agents\OrchestratorAgent; use Tests\Fixtures\Agents\ResearchAgent; -test('agent returned from tools is invoked when called by parent agent', function () { +test('agent returned from tools is invoked when called by parent agent', function (): void { DelegatingAgent::fake([ new ToolCall('call_123', 'research_agent', ['task' => 'Research Laravel']), 'Research delegated.', @@ -21,9 +21,7 @@ $response = (new DelegatingAgent)->prompt('Delegate research about Laravel'); DelegatingAgent::assertPrompted('Delegate research about Laravel'); - ResearchAgent::assertPrompted(function (AgentPrompt $prompt) { - return $prompt->prompt === 'Research Laravel'; - }); + ResearchAgent::assertPrompted(fn (AgentPrompt $prompt): bool => $prompt->prompt === 'Research Laravel'); expect($response->toolCalls)->toHaveCount(1) ->and($response->toolCalls->first()->name)->toBe('research_agent') @@ -31,7 +29,7 @@ ->and($response->toolResults->first()->result)->toBe('Research result'); }); -test('research agent can be faked independently', function () { +test('research agent can be faked independently', function (): void { ResearchAgent::fake(['Research result']); $response = (new ResearchAgent)->prompt('Research topic'); @@ -39,14 +37,14 @@ expect($response->text)->toBe('Research result'); }); -test('agent tool uses name and description from agent when defined', function () { +test('agent tool uses name and description from agent when defined', function (): void { $tool = new AgentTool(new ResearchAgent); expect($tool->name())->toBe('research_agent') ->and($tool->description())->toBe('Research a topic in depth and return a summary.'); }); -test('agent tool falls back to class basename for name when has tool metadata is not implemented', function () { +test('agent tool falls back to class basename for name when has tool metadata is not implemented', function (): void { $agent = new class implements Agent { use Promptable; @@ -65,7 +63,7 @@ public function instructions(): string ->toStartWith("Delegates a task to the {$name} sub-agent"); }); -test('agent tool falls back to a generic description that does not leak instructions', function () { +test('agent tool falls back to a generic description that does not leak instructions', function (): void { $agent = new class implements Agent { use Promptable; @@ -85,7 +83,7 @@ public function instructions(): string ->not->toContain('long internal instructions'); }); -test('framework wraps an agent in tools automatically when resolving', function () { +test('framework wraps an agent in tools automatically when resolving', function (): void { $tools = (new DelegatingAgent)->tools(); $resolved = array_map( @@ -97,7 +95,7 @@ public function instructions(): string ->and($resolved[0]->agent())->toBeInstanceOf(ResearchAgent::class); }); -test('nested agent delegates through a middle manager to a research agent', function () { +test('nested agent delegates through a middle manager to a research agent', function (): void { OrchestratorAgent::fake([ new ToolCall('call_001', 'middle_manager', ['task' => 'Deep-dive on Laravel caching']), 'Delegated to middle manager.', @@ -113,12 +111,8 @@ public function instructions(): string $response = (new OrchestratorAgent)->prompt('Do a deep dive on Laravel caching'); OrchestratorAgent::assertPrompted('Do a deep dive on Laravel caching'); - MiddleManagerAgent::assertPrompted(function (AgentPrompt $prompt) { - return $prompt->prompt === 'Deep-dive on Laravel caching'; - }); - ResearchAgent::assertPrompted(function (AgentPrompt $prompt) { - return $prompt->prompt === 'Research Laravel caching internals'; - }); + MiddleManagerAgent::assertPrompted(fn (AgentPrompt $prompt): bool => $prompt->prompt === 'Deep-dive on Laravel caching'); + ResearchAgent::assertPrompted(fn (AgentPrompt $prompt): bool => $prompt->prompt === 'Research Laravel caching internals'); expect($response->toolCalls)->toHaveCount(1) ->and($response->toolCalls->first()->name)->toBe('middle_manager') diff --git a/tests/Feature/TranscriptionFakeTest.php b/tests/Feature/TranscriptionFakeTest.php index 37f15e75e..d278cdeca 100644 --- a/tests/Feature/TranscriptionFakeTest.php +++ b/tests/Feature/TranscriptionFakeTest.php @@ -10,22 +10,22 @@ use Laravel\Ai\Responses\TranscriptionResponse; use Laravel\Ai\Transcription; -test('transcription rejects empty audio string', function () { +test('transcription rejects empty audio string', function (): void { Transcription::fake(); Transcription::of('')->generate(); })->throws(InvalidArgumentException::class, 'Base64 audio content cannot be empty.'); -test('transcription rejects empty base64 audio', function () { +test('transcription rejects empty base64 audio', function (): void { Transcription::fake(); Transcription::fromBase64('')->generate(); })->throws(InvalidArgumentException::class, 'Base64 audio content cannot be empty.'); -test('transcriptions can be faked', function () { +test('transcriptions can be faked', function (): void { Transcription::fake([ 'First transcription', - fn (TranscriptionPrompt $prompt) => 'Second transcription', + fn (TranscriptionPrompt $prompt): string => 'Second transcription', new TranscriptionResponse( 'Third transcription', new Collection([new TranscriptionSegment('Third transcription', 'Speaker 1', 0.0, 1.0)]), @@ -44,17 +44,17 @@ expect($response->text)->toEqual('Third transcription'); // Assertion tests... - Transcription::assertGenerated(fn (TranscriptionPrompt $prompt) => true); - Transcription::assertNotGenerated(fn (TranscriptionPrompt $prompt) => $prompt->language === 'fr'); + Transcription::assertGenerated(fn (TranscriptionPrompt $prompt): true => true); + Transcription::assertNotGenerated(fn (TranscriptionPrompt $prompt): bool => $prompt->language === 'fr'); }); -test('can assert no transcriptions were generated', function () { +test('can assert no transcriptions were generated', function (): void { Transcription::fake(); Transcription::assertNothingGenerated(); }); -test('transcriptions can be faked with no predefined responses', function () { +test('transcriptions can be faked with no predefined responses', function (): void { Transcription::fake(); $response = Transcription::of(base64_encode('audio-1'))->generate(); @@ -64,10 +64,10 @@ expect($response->text)->toEqual('Fake transcription text.'); }); -test('transcriptions can be faked with a single closure that is invoked for every generation', function () { +test('transcriptions can be faked with a single closure that is invoked for every generation', function (): void { $counter = 0; - Transcription::fake(function (TranscriptionPrompt $prompt) use (&$counter) { + Transcription::fake(function (TranscriptionPrompt $prompt) use (&$counter): string { $counter++; return "Transcription {$counter}"; @@ -80,43 +80,39 @@ expect($response->text)->toEqual('Transcription 2'); }); -test('transcriptions can prevent stray generations', function () { +test('transcriptions can prevent stray generations', function (): void { Transcription::fake()->preventStrayTranscriptions(); Transcription::of(base64_encode('audio'))->generate(); })->throws(RuntimeException::class); -test('fake closures can throw exceptions', function () { - Transcription::fake(function () { +test('fake closures can throw exceptions', function (): void { + Transcription::fake(function (): void { throw new Exception('Something went wrong'); }); Transcription::of(base64_encode('audio'))->generate(); })->throws(Exception::class); -test('transcription language and diarize are recorded', function () { +test('transcription language and diarize are recorded', function (): void { Transcription::fake(); Transcription::of(base64_encode('audio'))->language('en')->diarize()->generate(); - Transcription::assertGenerated(function (TranscriptionPrompt $prompt) { - return $prompt->language === 'en' && $prompt->isDiarized(); - }); + Transcription::assertGenerated(fn (TranscriptionPrompt $prompt): bool => $prompt->language === 'en' && $prompt->isDiarized()); }); -test('transcription provider options are recorded', function () { +test('transcription provider options are recorded', function (): void { Transcription::fake(); Transcription::of(base64_encode('audio')) ->withProviderOptions(['prompt' => 'Laravel Forge and Vapor']) ->generate(); - Transcription::assertGenerated(function (TranscriptionPrompt $prompt) { - return ($prompt->providerOptions['prompt'] ?? null) === 'Laravel Forge and Vapor'; - }); + Transcription::assertGenerated(fn (TranscriptionPrompt $prompt): bool => ($prompt->providerOptions['prompt'] ?? null) === 'Laravel Forge and Vapor'); }); -test('fake transcriptions include segments', function () { +test('fake transcriptions include segments', function (): void { Transcription::fake(['Hello world']); $response = Transcription::of(base64_encode('audio'))->generate(); @@ -126,73 +122,63 @@ ->and($response->segments[0]->speaker)->toEqual('Speaker 1'); }); -test('queued transcriptions can be faked', function () { +test('queued transcriptions can be faked', function (): void { Transcription::fake(); Transcription::fromPath('/path/to/audio.mp3')->queue(); - Transcription::assertQueued(fn (QueuedTranscriptionPrompt $prompt) => $prompt->audio->path === '/path/to/audio.mp3'); - Transcription::assertNotQueued(fn (QueuedTranscriptionPrompt $prompt) => $prompt->audio->path === '/path/to/other.mp3'); + Transcription::assertQueued(fn (QueuedTranscriptionPrompt $prompt): bool => $prompt->audio->path === '/path/to/audio.mp3'); + Transcription::assertNotQueued(fn (QueuedTranscriptionPrompt $prompt): bool => $prompt->audio->path === '/path/to/other.mp3'); - Transcription::assertQueued(function (QueuedTranscriptionPrompt $prompt) { - return $prompt->audio->path === '/path/to/audio.mp3'; - }); + Transcription::assertQueued(fn (QueuedTranscriptionPrompt $prompt): bool => $prompt->audio->path === '/path/to/audio.mp3'); - Transcription::assertNotQueued(function (QueuedTranscriptionPrompt $prompt) { - return $prompt->audio->path === '/path/to/other.mp3'; - }); + Transcription::assertNotQueued(fn (QueuedTranscriptionPrompt $prompt): bool => $prompt->audio->path === '/path/to/other.mp3'); }); -test('can assert no transcriptions were queued', function () { +test('can assert no transcriptions were queued', function (): void { Transcription::fake(); Transcription::assertNothingQueued(); }); -test('generate accepts ai provider enum', function () { +test('generate accepts ai provider enum', function (): void { Transcription::fake(); Transcription::of(base64_encode('audio'))->generate(provider: Lab::OpenAI); - Transcription::assertGenerated(fn (TranscriptionPrompt $prompt) => true); + Transcription::assertGenerated(fn (TranscriptionPrompt $prompt): true => true); }); -test('queued transcription accepts ai provider enum', function () { +test('queued transcription accepts ai provider enum', function (): void { Transcription::fake(); Transcription::fromPath('/path/to/audio.mp3')->queue(provider: Lab::ElevenLabs); - Transcription::assertQueued(fn (QueuedTranscriptionPrompt $prompt) => $prompt->provider === Lab::ElevenLabs); + Transcription::assertQueued(fn (QueuedTranscriptionPrompt $prompt): bool => $prompt->provider === Lab::ElevenLabs); }); -test('queued transcription language and diarize are recorded', function () { +test('queued transcription language and diarize are recorded', function (): void { Transcription::fake(); Transcription::fromPath('/path/to/audio.mp3')->language('es')->diarize()->queue(); - Transcription::assertQueued(function (QueuedTranscriptionPrompt $prompt) { - return $prompt->language === 'es' && $prompt->isDiarized(); - }); + Transcription::assertQueued(fn (QueuedTranscriptionPrompt $prompt): bool => $prompt->language === 'es' && $prompt->isDiarized()); }); -test('queued transcription provider options are recorded', function () { +test('queued transcription provider options are recorded', function (): void { Transcription::fake(); Transcription::fromPath('/path/to/audio.mp3') ->withProviderOptions(['prompt' => 'Laravel Forge and Vapor']) ->queue(); - Transcription::assertQueued(function (QueuedTranscriptionPrompt $prompt) { - return ($prompt->providerOptions['prompt'] ?? null) === 'Laravel Forge and Vapor'; - }); + Transcription::assertQueued(fn (QueuedTranscriptionPrompt $prompt): bool => ($prompt->providerOptions['prompt'] ?? null) === 'Laravel Forge and Vapor'); }); -test('transcription can have timeouts', function () { +test('transcription can have timeouts', function (): void { Transcription::fake(); Transcription::of(base64_encode('audio'))->timeout(60)->generate(); - Transcription::assertGenerated(function (TranscriptionPrompt $prompt) { - return $prompt->timeout === 60; - }); + Transcription::assertGenerated(fn (TranscriptionPrompt $prompt): bool => $prompt->timeout === 60); }); diff --git a/tests/Feature/TranscriptionProviderOptionsTest.php b/tests/Feature/TranscriptionProviderOptionsTest.php index 28d836f22..c0f66c02d 100644 --- a/tests/Feature/TranscriptionProviderOptionsTest.php +++ b/tests/Feature/TranscriptionProviderOptionsTest.php @@ -8,24 +8,24 @@ use Laravel\Ai\Providers\Provider; use Laravel\Ai\Transcription; -beforeEach(function () { +beforeEach(function (): void { config([ 'ai.providers.openai' => [...config('ai.providers.openai'), 'key' => 'test-key'], 'ai.providers.mistral' => [...config('ai.providers.mistral'), 'key' => 'test-key'], ]); }); -test('flat provider options are sent on the transcription request', function () { +test('flat provider options are sent on the transcription request', function (): void { Http::fake(['*' => Http::response(['text' => 'Hello', 'usage' => ['input_tokens' => 1, 'total_tokens' => 2]])]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') ->withProviderOptions(['prompt' => 'Laravel Forge and Vapor']) ->generate(provider: 'openai', model: 'gpt-4o-transcribe'); - Http::assertSent(fn (Request $request) => str_contains($request->body(), 'Laravel Forge and Vapor')); + Http::assertSent(fn (Request $request): bool => str_contains($request->body(), 'Laravel Forge and Vapor')); }); -test('closure resolver receives the resolved provider and applies per-provider options', function () { +test('closure resolver receives the resolved provider and applies per-provider options', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(['text' => 'Hello', 'usage' => ['input_tokens' => 1, 'total_tokens' => 2]]), 'api.mistral.ai/*' => Http::response(['text' => 'Hello', 'usage' => ['prompt_tokens' => 1, 'completion_tokens' => 1]]), @@ -34,7 +34,7 @@ $seen = []; Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') - ->withProviderOptions(function (Provider $provider) use (&$seen) { + ->withProviderOptions(function (Provider $provider) use (&$seen): array { $seen[] = $provider->driver(); return $provider->driver() === 'openai' @@ -45,28 +45,28 @@ expect($seen)->toBe(['openai']); - Http::assertSent(fn (Request $request) => str_contains($request->body(), 'OpenAI hint')); + Http::assertSent(fn (Request $request): bool => str_contains($request->body(), 'OpenAI hint')); }); -test('closure provider options are not recorded on the queued prompt fake', function () { +test('closure provider options are not recorded on the queued prompt fake', function (): void { Transcription::fake(); Transcription::fromPath('/path/to/audio.mp3') - ->withProviderOptions(fn (Provider $provider) => ['prompt' => 'hint']) + ->withProviderOptions(fn (Provider $provider): array => ['prompt' => 'hint']) ->queue(provider: 'openai'); Transcription::assertQueued( - fn (QueuedTranscriptionPrompt $prompt) => $prompt->providerOptions === [], + fn (QueuedTranscriptionPrompt $prompt): bool => $prompt->providerOptions === [], ); }); -test('closure provider options survive queue serialization round-trip', function () { +test('closure provider options survive queue serialization round-trip', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(['text' => 'Hello', 'usage' => ['input_tokens' => 1, 'total_tokens' => 2]]), ]); $pending = Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') - ->withProviderOptions(fn (Provider $provider) => ['prompt' => 'Serialized hint']); + ->withProviderOptions(fn (Provider $provider): array => ['prompt' => 'Serialized hint']); $job = new GenerateTranscription($pending, 'openai', 'gpt-4o-transcribe'); @@ -76,17 +76,17 @@ $restored->handle(); - Http::assertSent(fn (Request $request) => str_contains($request->body(), 'Serialized hint')); + Http::assertSent(fn (Request $request): bool => str_contains($request->body(), 'Serialized hint')); }); -test('closure resolver returning null is treated as no options', function () { +test('closure resolver returning null is treated as no options', function (): void { Http::fake([ 'api.openai.com/*' => Http::response(['text' => 'Hello', 'usage' => ['input_tokens' => 1, 'total_tokens' => 2]]), ]); Transcription::fromBase64(base64_encode('fake-audio'), 'audio/mp3') - ->withProviderOptions(fn () => null) + ->withProviderOptions(fn (): null => null) ->generate(provider: 'openai', model: 'gpt-4o-transcribe'); - Http::assertSent(fn (Request $request) => ! str_contains($request->body(), 'prompt')); + Http::assertSent(fn (Request $request): bool => ! str_contains($request->body(), 'prompt')); }); diff --git a/tests/Feature/WithoutBroadcastingTest.php b/tests/Feature/WithoutBroadcastingTest.php index 3e78afc32..73e8d249f 100644 --- a/tests/Feature/WithoutBroadcastingTest.php +++ b/tests/Feature/WithoutBroadcastingTest.php @@ -12,31 +12,31 @@ use Tests\Fixtures\Agents\NonBroadcastingTextAgent; use Tests\Fixtures\Agents\NonBroadcastingToolAgent; -test('it rejects event classes that are not stream events', function () { - expect(fn () => new WithoutBroadcasting('App\Events\Typo')) +test('it rejects event classes that are not stream events', function (): void { + expect(fn (): WithoutBroadcasting => new WithoutBroadcasting('App\Events\Typo')) ->toThrow(InvalidArgumentException::class); }); -test('no events are withheld when the attribute is absent', function () { +test('no events are withheld when the attribute is absent', function (): void { expect(WithoutBroadcasting::eventsFor(new AssistantAgent))->toBe([]); }); -test('listed events are withheld when the attribute is present', function () { +test('listed events are withheld when the attribute is present', function (): void { expect(WithoutBroadcasting::eventsFor(new NonBroadcastingToolAgent)) ->toContain(ToolResult::class) ->toContain(ToolCall::class); }); -test('events not listed in the attribute are not withheld', function () { +test('events not listed in the attribute are not withheld', function (): void { expect(WithoutBroadcasting::eventsFor(new NonBroadcastingToolAgent)) ->not->toContain(TextDelta::class); }); -test('a null target withholds nothing', function () { +test('a null target withholds nothing', function (): void { expect(WithoutBroadcasting::eventsFor(null))->toBe([]); }); -test('broadcast withholds listed events from the channel but assembles the full response', function () { +test('broadcast withholds listed events from the channel but assembles the full response', function (): void { Event::fake(); NonBroadcastingTextAgent::fake(['Hello world']); @@ -44,18 +44,18 @@ (new NonBroadcastingTextAgent) ->broadcastNow('Say hello', new Channel('test-channel')) - ->then(function ($response) use (&$received) { + ->then(function ($response) use (&$received): void { $received = $response; }); - Event::assertNotDispatched(AnonymousEvent::class, fn (AnonymousEvent $e) => $e->broadcastAs() === 'text_delta'); - Event::assertDispatched(AnonymousEvent::class, fn (AnonymousEvent $e) => $e->broadcastAs() === 'stream_start'); + Event::assertNotDispatched(AnonymousEvent::class, fn (AnonymousEvent $e): bool => $e->broadcastAs() === 'text_delta'); + Event::assertDispatched(AnonymousEvent::class, fn (AnonymousEvent $e): bool => $e->broadcastAs() === 'stream_start'); expect($received)->not->toBeNull('then() callback was never invoked') ->and($received->text)->toBe('Hello world'); }); -test('broadcast agent withholds listed events from the channel but resolves the full response', function () { +test('broadcast agent withholds listed events from the channel but resolves the full response', function (): void { Event::fake(); NonBroadcastingTextAgent::fake(['Hello world']); @@ -67,14 +67,14 @@ channels: new Channel('test-channel'), ); - $job->then(function ($response) use (&$received) { + $job->then(function ($response) use (&$received): void { $received = $response; }); $job->handle(); - Event::assertNotDispatched(AnonymousEvent::class, fn (AnonymousEvent $e) => $e->broadcastAs() === 'text_delta'); - Event::assertDispatched(AnonymousEvent::class, fn (AnonymousEvent $e) => $e->broadcastAs() === 'stream_start'); + Event::assertNotDispatched(AnonymousEvent::class, fn (AnonymousEvent $e): bool => $e->broadcastAs() === 'text_delta'); + Event::assertDispatched(AnonymousEvent::class, fn (AnonymousEvent $e): bool => $e->broadcastAs() === 'stream_start'); expect($received)->not->toBeNull('then() callback was never invoked') ->and($received->text)->toBe('Hello world'); diff --git a/tests/Fixtures/Agents/IncompatibleMethodsAgent.php b/tests/Fixtures/Agents/IncompatibleMethodsAgent.php index 888c9db9c..aa42137b9 100644 --- a/tests/Fixtures/Agents/IncompatibleMethodsAgent.php +++ b/tests/Fixtures/Agents/IncompatibleMethodsAgent.php @@ -29,9 +29,4 @@ protected function maxTokens(): int { return 256; } - - private function temperature(): float - { - return 0.1; - } } diff --git a/tests/Fixtures/Agents/RememberingToolAgent.php b/tests/Fixtures/Agents/RememberingToolAgent.php index c4a0abe5e..138a53962 100644 --- a/tests/Fixtures/Agents/RememberingToolAgent.php +++ b/tests/Fixtures/Agents/RememberingToolAgent.php @@ -12,7 +12,8 @@ class RememberingToolAgent implements Agent, HasProviderOptions, HasTools { - use Promptable, RemembersConversations; + use Promptable; + use RemembersConversations; public function __construct(public array $extraProviderOptions = []) {} diff --git a/tests/Fixtures/ConversationStores/InMemoryConversationStore.php b/tests/Fixtures/ConversationStores/InMemoryConversationStore.php index a3cf13853..776e0d30e 100644 --- a/tests/Fixtures/ConversationStores/InMemoryConversationStore.php +++ b/tests/Fixtures/ConversationStores/InMemoryConversationStore.php @@ -17,7 +17,7 @@ class InMemoryConversationStore implements ConversationStore public function latestConversationId(string|int $userId): ?string { return collect($this->conversations) - ->filter(fn ($conversation) => $conversation['user_id'] == $userId) + ->filter(fn ($conversation): bool => $conversation['user_id'] == $userId) ->keys() ->last(); } diff --git a/tests/Fixtures/Mcp/FakeArrayMcpServerTool.php b/tests/Fixtures/Mcp/FakeArrayMcpServerTool.php index 1e633e9ba..eb2623a4d 100644 --- a/tests/Fixtures/Mcp/FakeArrayMcpServerTool.php +++ b/tests/Fixtures/Mcp/FakeArrayMcpServerTool.php @@ -25,6 +25,7 @@ public function handle(Request $request): array /** * @return array */ + #[\Override] public function schema(JsonSchema $schema): array { return []; diff --git a/tests/Fixtures/Mcp/FakeErroringMcpServerTool.php b/tests/Fixtures/Mcp/FakeErroringMcpServerTool.php index edcd76c30..5085c7b8c 100644 --- a/tests/Fixtures/Mcp/FakeErroringMcpServerTool.php +++ b/tests/Fixtures/Mcp/FakeErroringMcpServerTool.php @@ -18,6 +18,7 @@ public function handle(Request $request): Response /** * @return array */ + #[\Override] public function schema(JsonSchema $schema): array { return []; diff --git a/tests/Fixtures/Mcp/FakeMcpServerTool.php b/tests/Fixtures/Mcp/FakeMcpServerTool.php index 847a2568c..21fd668cf 100644 --- a/tests/Fixtures/Mcp/FakeMcpServerTool.php +++ b/tests/Fixtures/Mcp/FakeMcpServerTool.php @@ -24,6 +24,7 @@ public function handle(Request $request): Response /** * @return array */ + #[\Override] public function schema(JsonSchema $schema): array { return [ diff --git a/tests/Fixtures/Mcp/FakeMcpTool.php b/tests/Fixtures/Mcp/FakeMcpTool.php index 9bc0709b2..c7409f95f 100644 --- a/tests/Fixtures/Mcp/FakeMcpTool.php +++ b/tests/Fixtures/Mcp/FakeMcpTool.php @@ -29,6 +29,7 @@ public function __construct( /** * @param array $arguments */ + #[\Override] public function call(array $arguments = []): ToolResult { return $this->fakeClient->callTool($this->name, $arguments); diff --git a/tests/Fixtures/Mcp/FakeStreamingMcpServerTool.php b/tests/Fixtures/Mcp/FakeStreamingMcpServerTool.php index 942fe1a33..a0c043527 100644 --- a/tests/Fixtures/Mcp/FakeStreamingMcpServerTool.php +++ b/tests/Fixtures/Mcp/FakeStreamingMcpServerTool.php @@ -25,6 +25,7 @@ public function handle(Request $request): Generator /** * @return array */ + #[\Override] public function schema(JsonSchema $schema): array { return []; diff --git a/tests/Fixtures/Mcp/FakeStructuredMcpServerTool.php b/tests/Fixtures/Mcp/FakeStructuredMcpServerTool.php index 80cabf2d6..e0b1f84a2 100644 --- a/tests/Fixtures/Mcp/FakeStructuredMcpServerTool.php +++ b/tests/Fixtures/Mcp/FakeStructuredMcpServerTool.php @@ -22,6 +22,7 @@ public function handle(Request $request): ResponseFactory /** * @return array */ + #[\Override] public function schema(JsonSchema $schema): array { return [ diff --git a/tests/Helpers.php b/tests/Helpers.php index 7189ac2d9..a33944282 100644 --- a/tests/Helpers.php +++ b/tests/Helpers.php @@ -29,10 +29,10 @@ function multipartField(Request $request, string $name): ?string function multipartNestedField(Request $request, string $name): array { return collect($request->data()) - ->reject(fn ($field) => isset($field['filename'])) - ->filter(fn ($field) => (($field['name'] ?? null) === $name && is_array($field['contents'] ?? null)) + ->reject(fn ($field): bool => isset($field['filename'])) + ->filter(fn ($field): bool => (($field['name'] ?? null) === $name && is_array($field['contents'] ?? null)) || preg_match('/^'.preg_quote($name, '/').'\[[^\]]*\]$/', $field['name'] ?? '') === 1) - ->flatMap(fn ($field) => is_array($field['contents'] ?? null) ? array_values($field['contents']) : [$field['contents'] ?? null]) + ->flatMap(fn ($field): array => is_array($field['contents'] ?? null) ? array_values($field['contents']) : [$field['contents'] ?? null]) ->values() ->all(); } diff --git a/tests/Integration/AgentFakeIsolationTest.php b/tests/Integration/AgentFakeIsolationTest.php index 704de895a..d38ffdcd6 100644 --- a/tests/Integration/AgentFakeIsolationTest.php +++ b/tests/Integration/AgentFakeIsolationTest.php @@ -3,15 +3,15 @@ use Tests\Fixtures\Agents\AssistantAgent; use Tests\Fixtures\Agents\SecondaryAssistantAgent; -beforeEach(function () { +beforeEach(function (): void { requiresApiKey('GROQ_API_KEY'); $this->provider = 'groq'; $this->model = 'openai/gpt-oss-20b'; }); -test('faking one agent doesnt affect another agent', function () { - AssistantAgent::fake(fn () => 'Fake response'); +test('faking one agent doesnt affect another agent', function (): void { + AssistantAgent::fake(fn (): string => 'Fake response'); $fakeResponse = (new AssistantAgent)->prompt( 'What is the name of the PHP framework created by Taylor Otwell?', diff --git a/tests/Integration/AgentReasoningTest.php b/tests/Integration/AgentReasoningTest.php index 8823b3f06..51797c29b 100644 --- a/tests/Integration/AgentReasoningTest.php +++ b/tests/Integration/AgentReasoningTest.php @@ -12,20 +12,20 @@ use Tests\Fixtures\Agents\RememberingToolAgent; use Tests\Fixtures\Tools\FixedNumberGenerator; -uses(RefreshDatabase::class)->beforeEach(function () { +uses(RefreshDatabase::class)->beforeEach(function (): void { $this->artisan('migrate', [ '--path' => __DIR__.'/../../database/migrations', '--realpath' => true, ])->run(); })->in(__FILE__); -test('models can replay conversation history with tool calls', function (string $provider, string $apiKey, string $model, bool $isReasoning) { +test('models can replay conversation history with tool calls', function (string $provider, string $apiKey, string $model, bool $isReasoning): void { requiresApiKey($apiKey); $tool = new FixedNumberGenerator; $instructions = 'For every request, call the FixedNumberGenerator tool before answering. Answer with one short sentence.'; - $makeAgent = fn (iterable $messages = []) => new class($instructions, $messages, [$tool], $isReasoning) implements Agent, Conversational, HasProviderOptions, HasTools + $makeAgent = fn (iterable $messages = []): object => new class($instructions, $messages, [$tool], $isReasoning) implements Agent, Conversational, HasProviderOptions, HasTools { use Promptable; @@ -92,7 +92,7 @@ public function providerOptions(Lab|string $provider): array expect($secondResponse->text)->toContain('72019'); })->with('tool-replay-providers'); -test('reasoning conversation roundtrips through database storage', function () { +test('reasoning conversation roundtrips through database storage', function (): void { requiresApiKey('OPENAI_API_KEY'); $user = (object) ['id' => 1]; @@ -121,7 +121,7 @@ public function providerOptions(Lab|string $provider): array expect($record)->not->toBeNull(); - $storedToolCalls = json_decode($record->tool_calls, true); + $storedToolCalls = json_decode((string) $record->tool_calls, true); expect($storedToolCalls)->toHaveCount(1) ->and($storedToolCalls[0]['reasoning_id'] ?? null)->not->toBeNull(); diff --git a/tests/Integration/AgentTest.php b/tests/Integration/AgentTest.php index 6d0544892..69bd451bf 100644 --- a/tests/Integration/AgentTest.php +++ b/tests/Integration/AgentTest.php @@ -31,7 +31,7 @@ use function Laravel\Ai\agent; -test('agents can get a simple text response', function (string $provider, string $apiKey, string $model) { +test('agents can get a simple text response', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); Event::fake(); @@ -55,7 +55,7 @@ Event::assertDispatched(AgentPrompted::class); })->with('agent-providers'); -test('ad hoc agents can be prompted', function (string $provider, string $apiKey, string $model) { +test('ad hoc agents can be prompted', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); $response = agent()->prompt( @@ -67,7 +67,7 @@ expect(str_contains($response->text, 'Laravel'))->toBeTrue(); })->with('agent-providers'); -test('agents can stream a response', function (string $provider, string $apiKey, string $model) { +test('agents can stream a response', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); Event::fake(); @@ -78,9 +78,9 @@ 'What is the name of the PHP framework created by Taylor Otwell?', provider: $provider, model: $model, - )->then(function (StreamedAgentResponse $response) { + )->then(function (StreamedAgentResponse $response): void { $_SERVER['__testing.response'] = $response; - })->then(function () { + })->then(function (): void { $_SERVER['__testing.invoked'] = true; }); @@ -93,7 +93,7 @@ expect((new Collection($events)) ->whereInstanceOf(TextDelta::class) ->isNotEmpty())->toBeTrue() - ->and(str_contains($response->text, 'Laravel'))->toBeTrue() + ->and(str_contains((string) $response->text, 'Laravel'))->toBeTrue() ->and($_SERVER['__testing.response']->events)->toHaveSameSize($events) ->and($_SERVER['__testing.invoked'])->toBeTrue(); @@ -104,7 +104,7 @@ unset($_SERVER['__testing.invoked']); })->with('agent-providers'); -test('agents can queue a response', function (string $provider, string $apiKey, string $model) { +test('agents can queue a response', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); $agent = new AssistantAgent; @@ -113,58 +113,58 @@ 'What is the name of the PHP framework created by Taylor Otwell?', provider: $provider, model: $model, - )->then(function (AgentResponse $response) { + )->then(function (AgentResponse $response): void { $_ENV['__testing.response'] = $response; }); $response = $_ENV['__testing.response']; - expect(str_contains($response->text, 'Laravel'))->toBeTrue(); + expect(str_contains((string) $response->text, 'Laravel'))->toBeTrue(); unset($_SERVER['__testing.response']); })->with('agent-providers'); -test('ad hoc agents can queue a response', function (string $provider, string $apiKey, string $model) { +test('ad hoc agents can queue a response', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); agent()->queue( 'What is the name of the PHP framework created by Taylor Otwell?', provider: $provider, model: $model, - )->then(function (AgentResponse $response) { + )->then(function (AgentResponse $response): void { $_ENV['__testing.response'] = $response; }); $response = $_ENV['__testing.response']; - expect(str_contains($response->text, 'Laravel'))->toBeTrue(); + expect(str_contains((string) $response->text, 'Laravel'))->toBeTrue(); unset($_SERVER['__testing.response']); })->with('agent-providers'); -test('ad hoc structured agents can queue a response', function (string $provider, string $apiKey, string $model) { +test('ad hoc structured agents can queue a response', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); agent( - schema: fn ($schema) => [ + schema: fn ($schema): array => [ 'symbol' => $schema->string()->required(), ] )->queue( 'What is the chemical symbol for silver?', provider: $provider, model: $model, - )->then(function (AgentResponse $response) { + )->then(function (AgentResponse $response): void { $_ENV['__testing.response'] = $response; }); $response = $_ENV['__testing.response']; - expect(strtolower($response['symbol']))->toEqual('ag'); + expect(strtolower((string) $response['symbol']))->toEqual('ag'); unset($_SERVER['__testing.response']); })->with('agent-providers'); -test('agents can have conversation state', function (string $provider, string $apiKey, string $model) { +test('agents can have conversation state', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); $agent = new ConversationalAgent; @@ -178,7 +178,7 @@ expect(str_contains($response->text, 'Taylor'))->toBeTrue(); })->with('agent-providers'); -test('agents can have structured output', function (string $provider, string $apiKey, string $model) { +test('agents can have structured output', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); $agent = new StructuredAgent; @@ -189,15 +189,15 @@ model: $model, ); - expect(strtolower($response['symbol']))->toEqual('ag') + expect(strtolower((string) $response['symbol']))->toEqual('ag') ->and($response->steps->count())->toBeGreaterThan(0); })->with('agent-providers'); -test('ad hoc agents can have structured output', function (string $provider, string $apiKey, string $model) { +test('ad hoc agents can have structured output', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); $response = agent( - schema: fn (JsonSchema $schema) => [ + schema: fn (JsonSchema $schema): array => [ 'symbol' => $schema->string()->required(), ], )->prompt( @@ -206,10 +206,10 @@ model: $model, ); - expect(strtolower($response['symbol']))->toEqual('ag'); + expect(strtolower((string) $response['symbol']))->toEqual('ag'); })->with('agent-providers'); -test('agents can use tools', function (string $provider, string $apiKey, string $model) { +test('agents can use tools', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); Event::fake(); @@ -229,9 +229,7 @@ Event::assertDispatched(InvokingTool::class); - Event::assertDispatched(ToolInvoked::class, function ($event) { - return ! is_null($event->toolInvocationId); - }); + Event::assertDispatched(ToolInvoked::class, fn ($event): bool => ! is_null($event->toolInvocationId)); // Verify with a fixed response... $agent = new ToolUsingAgent(fixed: true); @@ -245,13 +243,13 @@ expect($response['number'])->toBe(72019); })->with('agent-providers'); -test('agents can replay empty tool arguments', function (string $provider, string $apiKey, string $model) { +test('agents can replay empty tool arguments', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); $tool = new FixedNumberGenerator; $instructions = 'For every request, call the FixedNumberGenerator tool before answering. Answer with one short sentence.'; $firstPrompt = 'What fixed number is available?'; - $makeAgent = fn (iterable $messages = []) => new class($instructions, $messages, [$tool]) implements Agent, Conversational, HasProviderOptions, HasTools + $makeAgent = fn (iterable $messages = []): object => new class($instructions, $messages, [$tool]) implements Agent, Conversational, HasProviderOptions, HasTools { use Promptable; @@ -309,7 +307,7 @@ public function providerOptions(Lab|string $provider): array ->and($secondResponse->toolCalls->first()->arguments)->toBe([]); })->with('agent-providers'); -test('agents can analyze text document attachments', function (string $provider, string $apiKey, string $model) { +test('agents can analyze text document attachments', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); Storage::fake('docs'); @@ -338,7 +336,7 @@ public function providerOptions(Lab|string $provider): array ->and($response->text)->toContain('Taylor'); })->with('agent-document-providers'); -test('agents can analyze local image attachments with a detected mime type', function (string $provider, string $apiKey, string $model, string $file, string $color) { +test('agents can analyze local image attachments with a detected mime type', function (string $provider, string $apiKey, string $model, string $file, string $color): void { requiresApiKey($apiKey); $response = agent('Answer briefly.')->prompt( @@ -354,7 +352,7 @@ public function providerOptions(Lab|string $provider): array 'jpeg' => ['blue.jpg', 'blue'], ]); -test('agent tool exception handling is not magical', function (string $provider, string $apiKey, string $model) { +test('agent tool exception handling is not magical', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); Event::fake(); @@ -371,11 +369,11 @@ public function providerOptions(Lab|string $provider): array ); $text = $response->text; - } catch (Exception $e) { + } catch (Exception $exception) { $caught = true; - expect($e)->toBeInstanceOf(Exception::class) - ->and($e->getMessage())->toEqual('Forced to throw exception.'); + expect($exception)->toBeInstanceOf(Exception::class) + ->and($exception->getMessage())->toEqual('Forced to throw exception.'); } expect($caught)->toBeTrue(); diff --git a/tests/Integration/AiProviderEnumTest.php b/tests/Integration/AiProviderEnumTest.php index 99e563adb..18feffd86 100644 --- a/tests/Integration/AiProviderEnumTest.php +++ b/tests/Integration/AiProviderEnumTest.php @@ -16,7 +16,7 @@ beforeEach(fn () => requiresApiKey('GROQ_API_KEY', 'OPENAI_API_KEY')); -test('agent prompt accepts ai provider enum', function () { +test('agent prompt accepts ai provider enum', function (): void { Event::fake(); $response = (new AssistantAgent)->prompt( @@ -31,7 +31,7 @@ Event::assertDispatched(AgentPrompted::class); }); -test('agent stream accepts ai provider enum', function () { +test('agent stream accepts ai provider enum', function (): void { Event::fake(); $response = (new AssistantAgent)->stream( @@ -47,28 +47,28 @@ } expect(collect($events)->whereInstanceOf(TextDelta::class)->isNotEmpty())->toBeTrue() - ->and(str_contains($response->text, 'Laravel'))->toBeTrue(); + ->and(str_contains((string) $response->text, 'Laravel'))->toBeTrue(); Event::assertDispatched(AgentStreamed::class); }); -test('agent queue accepts ai provider enum', function () { +test('agent queue accepts ai provider enum', function (): void { (new AssistantAgent)->queue( 'What is the name of the PHP framework created by Taylor Otwell?', provider: Lab::Groq, model: 'openai/gpt-oss-20b', - )->then(function (AgentResponse $response) { + )->then(function (AgentResponse $response): void { $_ENV['__testing.enum_queue_response'] = $response; }); $response = $_ENV['__testing.enum_queue_response']; - expect(str_contains($response->text, 'Laravel'))->toBeTrue(); + expect(str_contains((string) $response->text, 'Laravel'))->toBeTrue(); unset($_ENV['__testing.enum_queue_response']); }); -test('agent prompt accepts array of ai provider enum values for failover', function () { +test('agent prompt accepts array of ai provider enum values for failover', function (): void { $response = (new AssistantAgent)->prompt( 'What is the name of the PHP framework created by Taylor Otwell?', provider: [Lab::Groq], @@ -79,7 +79,7 @@ ->and($response->meta->provider)->toEqual('groq'); }); -test('embeddings generate accepts ai provider enum', function () { +test('embeddings generate accepts ai provider enum', function (): void { Event::fake(); $response = Embeddings::for(['I love to watch Star Trek.']) @@ -93,14 +93,14 @@ Event::assertDispatched(EmbeddingsGenerated::class); }); -test('audio generate accepts ai provider enum', function () { +test('audio generate accepts ai provider enum', function (): void { $response = Audio::of('Hello there! How are you today?') ->generate(provider: Lab::OpenAI); expect($response->meta->provider)->toEqual('openai'); }); -test('transcription generate accepts ai provider enum', function () { +test('transcription generate accepts ai provider enum', function (): void { $audio = Audio::of('Hello there! How are you today?')->generate(); $transcription = Transcription::of($audio->audio) diff --git a/tests/Integration/AudioTest.php b/tests/Integration/AudioTest.php index b39b6a02e..34821e0d2 100644 --- a/tests/Integration/AudioTest.php +++ b/tests/Integration/AudioTest.php @@ -7,7 +7,7 @@ use Laravel\Ai\Files; use Laravel\Ai\Transcription; -test('audio can be generated', function (string $provider, string $apiKey) { +test('audio can be generated', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); Event::fake(); @@ -18,11 +18,11 @@ ->and($response->audio)->not->toBeEmpty() ->and($response->mimeType())->not->toBeNull(); - Event::assertDispatched(GeneratingAudio::class, fn (GeneratingAudio $event) => $event->prompt->timeout === 30); - Event::assertDispatched(AudioGenerated::class, fn (AudioGenerated $event) => $event->prompt->timeout === 30); + Event::assertDispatched(GeneratingAudio::class, fn (GeneratingAudio $event): bool => $event->prompt->timeout === 30); + Event::assertDispatched(AudioGenerated::class, fn (AudioGenerated $event): bool => $event->prompt->timeout === 30); })->with('tts-providers'); -test('transcription can be generated from local path', function (string $provider, string $apiKey) { +test('transcription can be generated from local path', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); $transcription = Files\Audio::fromPath(__DIR__.'/../Fixtures/audio.mp3') @@ -32,7 +32,7 @@ expect(str_contains(strtolower((string) $transcription), 'how are you today'))->toBeTrue(); })->with('transcription-providers'); -test('audio can be transcribed after generation', function (string $provider, string $apiKey) { +test('audio can be transcribed after generation', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); $audio = Audio::of('Hello there! How are you today?')->generate(provider: $provider); @@ -43,7 +43,7 @@ expect(str_contains(strtolower((string) $transcription), 'how are you today'))->toBeTrue(); })->with('tts-providers'); -test('transcription can be diarized', function (string $provider, string $apiKey) { +test('transcription can be diarized', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); $audio = Audio::of('Hello there! How are you today?')->generate(provider: $provider); diff --git a/tests/Integration/EmbeddingsTest.php b/tests/Integration/EmbeddingsTest.php index 19ace7296..5cb1875c0 100644 --- a/tests/Integration/EmbeddingsTest.php +++ b/tests/Integration/EmbeddingsTest.php @@ -11,7 +11,7 @@ use Laravel\Ai\Providers\Provider; use Laravel\Ai\Responses\EmbeddingsResponse; -test('embeddings can be generated', function (string $provider, string $apiKey, int $dimensions) { +test('embeddings can be generated', function (string $provider, string $apiKey, int $dimensions): void { requiresApiKey($apiKey); Event::fake(); @@ -22,11 +22,11 @@ ->and($response->embeddings[0])->toHaveCount($dimensions) ->and($response->meta->provider)->toEqual($provider); - Event::assertDispatched(GeneratingEmbeddings::class, fn (GeneratingEmbeddings $event) => $event->prompt->timeout === 30); - Event::assertDispatched(EmbeddingsGenerated::class, fn (EmbeddingsGenerated $event) => $event->prompt->timeout === 30); + Event::assertDispatched(GeneratingEmbeddings::class, fn (GeneratingEmbeddings $event): bool => $event->prompt->timeout === 30); + Event::assertDispatched(EmbeddingsGenerated::class, fn (EmbeddingsGenerated $event): bool => $event->prompt->timeout === 30); })->with('embedding-providers'); -test('embeddings can be generated with custom dimensions', function (string $provider, string $apiKey) { +test('embeddings can be generated with custom dimensions', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); $response = Embeddings::for(['test text']) @@ -37,7 +37,7 @@ ->and($response->embeddings[0])->toHaveCount(256); })->with('embedding-providers'); -test('queued embeddings with closure provider options run end-to-end through a real queue driver', function (string $provider, string $apiKey, int $dimensions) { +test('queued embeddings with closure provider options run end-to-end through a real queue driver', function (string $provider, string $apiKey, int $dimensions): void { requiresApiKey($apiKey); config([ @@ -52,7 +52,7 @@ ], ]); - Schema::create('jobs', function (Blueprint $table) { + Schema::create('jobs', function (Blueprint $table): void { $table->bigIncrements('id'); $table->string('queue')->index(); $table->longText('payload'); @@ -65,7 +65,7 @@ Event::fake([EmbeddingsGenerated::class]); Embeddings::for(['I love to watch Star Trek.']) - ->withProviderOptions(fn (Provider $resolved) => match ($resolved->driver()) { + ->withProviderOptions(fn (Provider $resolved): array => match ($resolved->driver()) { 'voyageai' => ['input_type' => 'query'], default => [], }) @@ -79,7 +79,7 @@ Event::assertDispatched( EmbeddingsGenerated::class, - fn (EmbeddingsGenerated $event) => $event->response->meta->provider === $provider + fn (EmbeddingsGenerated $event): bool => $event->response->meta->provider === $provider && count($event->response->embeddings[0]) === $dimensions, ); })->with('embedding-providers'); diff --git a/tests/Integration/FileTest.php b/tests/Integration/FileTest.php index 9a38c3b5d..3b75a98b3 100644 --- a/tests/Integration/FileTest.php +++ b/tests/Integration/FileTest.php @@ -8,7 +8,7 @@ use Laravel\Ai\Events\StoringFile; use Laravel\Ai\Files\Document; -test('can store files', function (string $provider, string $apiKey) { +test('can store files', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); Event::fake(); @@ -27,7 +27,7 @@ Event::assertDispatched(FileDeleted::class); })->with('file-providers'); -test('can store files from local paths', function (string $provider, string $apiKey) { +test('can store files from local paths', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); $response = Document::fromPath(__DIR__.'/../Fixtures/document.txt')->put( @@ -39,7 +39,7 @@ Document::fromId($response->id)->delete(provider: $provider); })->with('file-providers'); -test('can store files from storage paths', function (string $provider, string $apiKey) { +test('can store files from storage paths', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); Storage::disk('local')->put('document.txt', 'Hello, World!'); @@ -53,7 +53,7 @@ Document::fromId($response->id)->delete(provider: $provider); })->with('file-providers'); -test('can store files from remote paths', function (string $provider, string $apiKey) { +test('can store files from remote paths', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); $stored = Document::fromUrl( @@ -72,7 +72,7 @@ Document::fromId($response->id)->delete(provider: $provider); })->with('file-providers'); -test('exception is thrown if stored file does not exist', function (string $provider, string $apiKey) { +test('exception is thrown if stored file does not exist', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); Document::fromStorage('missing-document.pdf', disk: 'local')->put( @@ -80,7 +80,7 @@ ); })->with('file-providers')->throws(RuntimeException::class); -test('can get files', function (string $provider, string $apiKey) { +test('can get files', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); $stored = Document::fromString('Hello, World!', 'text/plain')->put( @@ -96,7 +96,7 @@ Document::fromId($response->id)->delete(provider: $provider); })->with('file-providers'); -test('can delete files', function (string $provider, string $apiKey) { +test('can delete files', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); $stored = Document::fromString('Hello, World!', 'text/plain')->put( diff --git a/tests/Integration/ImageTest.php b/tests/Integration/ImageTest.php index 0d25db1dc..fcec7bdaf 100644 --- a/tests/Integration/ImageTest.php +++ b/tests/Integration/ImageTest.php @@ -2,7 +2,7 @@ use Laravel\Ai\Image; -test('images can be generated', function (string $provider, string $apiKey, string $model) { +test('images can be generated', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); $response = Image::of('Donut sitting on a kitchen counter.') @@ -11,7 +11,7 @@ expect($response->meta->provider)->toEqual($provider); })->with('image-providers'); -test('images can be generated with square size', function (string $provider, string $apiKey, string $model) { +test('images can be generated with square size', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); $response = Image::of('Donut sitting on a kitchen counter.') diff --git a/tests/Integration/McpClientTest.php b/tests/Integration/McpClientTest.php index 19b751155..cf40b6813 100644 --- a/tests/Integration/McpClientTest.php +++ b/tests/Integration/McpClientTest.php @@ -13,7 +13,7 @@ function requiresMcpServerEverything(): void } } -test('every weird MCP tool from the everything server works across providers', function (string $provider, string $apiKey, string $model) { +test('every weird MCP tool from the everything server works across providers', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); requiresMcpServerEverything(); @@ -39,7 +39,7 @@ function requiresMcpServerEverything(): void model: $model, ); - expect($response->toolCalls->contains(fn ($call) => $call->name === 'mcp_tools_echo'))->toBeTrue() + expect($response->toolCalls->contains(fn ($call): bool => $call->name === 'mcp_tools_echo'))->toBeTrue() ->and($response->text)->toContain('hello-mcp-123'); } finally { $client->disconnect(); diff --git a/tests/Integration/MultiAgentTest.php b/tests/Integration/MultiAgentTest.php index f687350b8..993a76586 100644 --- a/tests/Integration/MultiAgentTest.php +++ b/tests/Integration/MultiAgentTest.php @@ -9,7 +9,7 @@ use Tests\Fixtures\Agents\DelegatingAgent; use Tests\Fixtures\Agents\ResearchAgent; -test('a parent agent delegates to a sub-agent and the sub-agent runs end-to-end', function (string $provider, string $apiKey, string $model) { +test('a parent agent delegates to a sub-agent and the sub-agent runs end-to-end', function (string $provider, string $apiKey, string $model): void { requiresApiKey($apiKey); config(['ai.default' => $provider]); @@ -28,15 +28,13 @@ ->and($researchToolCall->arguments)->toHaveKey('task') ->and($researchToolCall->arguments['task'])->toBeString()->not->toBeEmpty(); - Event::assertDispatched(InvokingTool::class, function ($event) use ($researchToolCall) { - return $event->tool instanceof AgentTool - && $event->tool->agent() instanceof ResearchAgent - && ($event->arguments['task'] ?? null) === $researchToolCall->arguments['task']; - }); + Event::assertDispatched(InvokingTool::class, fn ($event): bool => $event->tool instanceof AgentTool + && $event->tool->agent() instanceof ResearchAgent + && ($event->arguments['task'] ?? null) === $researchToolCall->arguments['task']); $researchPrompted = null; - Event::assertDispatched(AgentPrompted::class, function ($event) use (&$researchPrompted) { + Event::assertDispatched(AgentPrompted::class, function ($event) use (&$researchPrompted): bool { if ($event->prompt->agent instanceof ResearchAgent) { $researchPrompted = $event; @@ -50,18 +48,14 @@ ->and($researchPrompted->prompt->prompt)->toBe($researchToolCall->arguments['task']) ->and($researchPrompted->response->text)->toBeString()->not->toBeEmpty(); - Event::assertDispatched(PromptingAgent::class, function ($event) { - return $event->prompt->agent instanceof ResearchAgent; - }); + Event::assertDispatched(PromptingAgent::class, fn ($event): bool => $event->prompt->agent instanceof ResearchAgent); $researchToolResult = $response->toolResults->firstWhere('name', 'research_agent'); expect($researchToolResult)->not->toBeNull() ->and($researchToolResult->result)->toBe($researchPrompted->response->text); - Event::assertDispatched(ToolInvoked::class, function ($event) use ($researchPrompted) { - return $event->tool instanceof AgentTool - && $event->tool->agent() instanceof ResearchAgent - && $event->result === $researchPrompted->response->text; - }); + Event::assertDispatched(ToolInvoked::class, fn ($event): bool => $event->tool instanceof AgentTool + && $event->tool->agent() instanceof ResearchAgent + && $event->result === $researchPrompted->response->text); })->with('agent-providers'); diff --git a/tests/Integration/RerankingTest.php b/tests/Integration/RerankingTest.php index fe6c32c48..22921fc11 100644 --- a/tests/Integration/RerankingTest.php +++ b/tests/Integration/RerankingTest.php @@ -7,7 +7,7 @@ use Laravel\Ai\Reranking as RerankingFacade; use Laravel\Ai\Responses\RerankingResponse; -test('documents can be reranked', function (string $provider, string $apiKey) { +test('documents can be reranked', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); Event::fake(); @@ -27,7 +27,7 @@ Event::assertDispatched(Reranked::class); })->with('reranking-providers'); -test('documents can be reranked with limit', function (string $provider, string $apiKey) { +test('documents can be reranked with limit', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); $response = RerankingFacade::of([ @@ -43,7 +43,7 @@ ->and($response->first()->document)->toContain('Laravel'); })->with('reranking-providers'); -test('collections can be reranked using string field', function (string $provider, string $apiKey) { +test('collections can be reranked using string field', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); $items = new Collection([ @@ -58,7 +58,7 @@ ->and($reranked->first()['id'])->toEqual(2); })->with('reranking-providers'); -test('collections can be reranked using array fields', function (string $provider, string $apiKey) { +test('collections can be reranked using array fields', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); $items = new Collection([ @@ -73,7 +73,7 @@ ->and($reranked->first()['id'])->toEqual(2); })->with('reranking-providers'); -test('collections can be reranked using closure', function (string $provider, string $apiKey) { +test('collections can be reranked using closure', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); $items = new Collection([ @@ -83,7 +83,7 @@ ]); $reranked = $items->rerank( - fn ($item) => $item['title'].': '.$item['body'], + fn ($item): string => $item['title'].': '.$item['body'], 'PHP frameworks', limit: 2, provider: $provider diff --git a/tests/Integration/StoreTest.php b/tests/Integration/StoreTest.php index 726e0d55a..c592a3cf6 100644 --- a/tests/Integration/StoreTest.php +++ b/tests/Integration/StoreTest.php @@ -7,6 +7,7 @@ use Laravel\Ai\Files; use Laravel\Ai\Files\Document; use Laravel\Ai\Providers\Tools\FileSearch; +use Laravel\Ai\Store; use Laravel\Ai\Stores; use function Illuminate\Support\days; @@ -31,7 +32,7 @@ function createFileSearchStore(string $provider): array metadata: ['company' => 'tailwind'], )->fileId; - $store = retry(60, function () use ($store) { + $store = retry(60, function () use ($store): Store { $refreshed = $store->refresh(); if ($refreshed->fileCounts->completed < 2) { @@ -44,7 +45,7 @@ function createFileSearchStore(string $provider): array return [$store, $fileIds]; } -test('can create get and delete store', function (string $provider, string $apiKey) { +test('can create get and delete store', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); Event::fake(); @@ -70,7 +71,7 @@ function createFileSearchStore(string $provider): array Event::assertDispatched(StoreDeleted::class); })->with('store-providers'); -test('can create store with expiration', function (string $provider, string $apiKey) { +test('can create store with expiration', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); $created = Stores::create( @@ -85,7 +86,7 @@ function createFileSearchStore(string $provider): array Stores::delete($created->id, provider: $provider); })->with('store-providers'); -test('can add and remove file from store', function (string $provider, string $apiKey) { +test('can add and remove file from store', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); // Create a store... @@ -116,9 +117,9 @@ function createFileSearchStore(string $provider): array $store->delete(); })->with('store-providers'); -describe('file search', function () { - afterEach(function () { - if (isset($this->fileSearchStore)) { +describe('file search', function (): void { + afterEach(function (): void { + if (property_exists($this, 'fileSearchStore') && $this->fileSearchStore !== null) { $this->fileSearchStore->delete(); } @@ -127,7 +128,7 @@ function createFileSearchStore(string $provider): array } }); - test('can actually prompt an agent with file search data', function (string $provider, string $apiKey) { + test('can actually prompt an agent with file search data', function (string $provider, string $apiKey): void { requiresApiKey($apiKey); $this->provider = $provider; @@ -143,7 +144,7 @@ function createFileSearchStore(string $provider): array expect((string) $response)->toContain('Yes')->toContain('Valkey'); })->with('file-search-providers'); - test('can actually prompt an agent with filtered search data', function () { + test('can actually prompt an agent with filtered search data', function (): void { requiresApiKey('OPENAI_API_KEY'); $this->provider = 'openai'; diff --git a/tests/Unit/Attributes/StrictTest.php b/tests/Unit/Attributes/StrictTest.php index dd09b1764..a7bea96cc 100644 --- a/tests/Unit/Attributes/StrictTest.php +++ b/tests/Unit/Attributes/StrictTest.php @@ -4,14 +4,14 @@ use Tests\Fixtures\Tools\NonStrictTool; use Tests\Fixtures\Tools\RandomNumberGenerator; -test('isAppliedTo returns true when target has the attribute', function () { +test('isAppliedTo returns true when target has the attribute', function (): void { expect(Strict::isAppliedTo(new RandomNumberGenerator))->toBeTrue(); }); -test('isAppliedTo returns false when target does not have the attribute', function () { +test('isAppliedTo returns false when target does not have the attribute', function (): void { expect(Strict::isAppliedTo(new NonStrictTool))->toBeFalse(); }); -test('isAppliedTo returns false when target is null', function () { +test('isAppliedTo returns false when target is null', function (): void { expect(Strict::isAppliedTo(null))->toBeFalse(); }); diff --git a/tests/Unit/Files/AudioPathTest.php b/tests/Unit/Files/AudioPathTest.php index 88973e5eb..a962ab09f 100644 --- a/tests/Unit/Files/AudioPathTest.php +++ b/tests/Unit/Files/AudioPathTest.php @@ -3,18 +3,18 @@ use Laravel\Ai\Files\LocalAudio; use Laravel\Ai\Files\StoredAudio; -test('local audio rejects empty path', function () { +test('local audio rejects empty path', function (): void { new LocalAudio(''); })->throws(InvalidArgumentException::class, 'Audio file path cannot be empty.'); -test('local audio rejects whitespace-only path', function () { +test('local audio rejects whitespace-only path', function (): void { new LocalAudio(" \t\n"); })->throws(InvalidArgumentException::class, 'Audio file path cannot be empty.'); -test('stored audio rejects empty path', function () { +test('stored audio rejects empty path', function (): void { new StoredAudio(''); })->throws(InvalidArgumentException::class, 'Audio file path cannot be empty.'); -test('stored audio rejects whitespace-only path', function () { +test('stored audio rejects whitespace-only path', function (): void { new StoredAudio(" \t\n"); })->throws(InvalidArgumentException::class, 'Audio file path cannot be empty.'); diff --git a/tests/Unit/Files/Base64AudioTest.php b/tests/Unit/Files/Base64AudioTest.php index 12a211a3d..433f04294 100644 --- a/tests/Unit/Files/Base64AudioTest.php +++ b/tests/Unit/Files/Base64AudioTest.php @@ -2,6 +2,6 @@ use Laravel\Ai\Files\Base64Audio; -test('base64 audio rejects empty content', function () { +test('base64 audio rejects empty content', function (): void { new Base64Audio(''); })->throws(InvalidArgumentException::class, 'Base64 audio content cannot be empty.'); diff --git a/tests/Unit/Files/DocumentPathTest.php b/tests/Unit/Files/DocumentPathTest.php index bebcffbd2..db56f22be 100644 --- a/tests/Unit/Files/DocumentPathTest.php +++ b/tests/Unit/Files/DocumentPathTest.php @@ -3,18 +3,18 @@ use Laravel\Ai\Files\LocalDocument; use Laravel\Ai\Files\StoredDocument; -test('local document rejects empty path', function () { +test('local document rejects empty path', function (): void { new LocalDocument(''); })->throws(InvalidArgumentException::class, 'Document file path cannot be empty.'); -test('local document rejects whitespace-only path', function () { +test('local document rejects whitespace-only path', function (): void { new LocalDocument(" \t\n"); })->throws(InvalidArgumentException::class, 'Document file path cannot be empty.'); -test('stored document rejects empty path', function () { +test('stored document rejects empty path', function (): void { new StoredDocument(''); })->throws(InvalidArgumentException::class, 'Document file path cannot be empty.'); -test('stored document rejects whitespace-only path', function () { +test('stored document rejects whitespace-only path', function (): void { new StoredDocument(" \t\n"); })->throws(InvalidArgumentException::class, 'Document file path cannot be empty.'); diff --git a/tests/Unit/Files/ImagePathTest.php b/tests/Unit/Files/ImagePathTest.php index 3158ba8f0..7adf98a0d 100644 --- a/tests/Unit/Files/ImagePathTest.php +++ b/tests/Unit/Files/ImagePathTest.php @@ -3,18 +3,18 @@ use Laravel\Ai\Files\LocalImage; use Laravel\Ai\Files\StoredImage; -test('local image rejects empty path', function () { +test('local image rejects empty path', function (): void { new LocalImage(''); })->throws(InvalidArgumentException::class, 'Image file path cannot be empty.'); -test('local image rejects whitespace-only path', function () { +test('local image rejects whitespace-only path', function (): void { new LocalImage(" \t\n"); })->throws(InvalidArgumentException::class, 'Image file path cannot be empty.'); -test('stored image rejects empty path', function () { +test('stored image rejects empty path', function (): void { new StoredImage(''); })->throws(InvalidArgumentException::class, 'Image file path cannot be empty.'); -test('stored image rejects whitespace-only path', function () { +test('stored image rejects whitespace-only path', function (): void { new StoredImage(" \t\n"); })->throws(InvalidArgumentException::class, 'Image file path cannot be empty.'); diff --git a/tests/Unit/Files/RemoteUrlTest.php b/tests/Unit/Files/RemoteUrlTest.php index f6c234e9d..91cf2835b 100644 --- a/tests/Unit/Files/RemoteUrlTest.php +++ b/tests/Unit/Files/RemoteUrlTest.php @@ -4,26 +4,26 @@ use Laravel\Ai\Files\RemoteDocument; use Laravel\Ai\Files\RemoteImage; -test('remote document rejects empty url', function () { +test('remote document rejects empty url', function (): void { new RemoteDocument(''); })->throws(InvalidArgumentException::class, 'Remote document URL cannot be empty.'); -test('remote document rejects whitespace-only url', function () { +test('remote document rejects whitespace-only url', function (): void { new RemoteDocument(" \t\n"); })->throws(InvalidArgumentException::class, 'Remote document URL cannot be empty.'); -test('remote image rejects empty url', function () { +test('remote image rejects empty url', function (): void { new RemoteImage(''); })->throws(InvalidArgumentException::class, 'Remote image URL cannot be empty.'); -test('remote image rejects whitespace-only url', function () { +test('remote image rejects whitespace-only url', function (): void { new RemoteImage(" \t\n"); })->throws(InvalidArgumentException::class, 'Remote image URL cannot be empty.'); -test('remote audio rejects empty url', function () { +test('remote audio rejects empty url', function (): void { new RemoteAudio(''); })->throws(InvalidArgumentException::class, 'Remote audio URL cannot be empty.'); -test('remote audio rejects whitespace-only url', function () { +test('remote audio rejects whitespace-only url', function (): void { new RemoteAudio(" \t\n"); })->throws(InvalidArgumentException::class, 'Remote audio URL cannot be empty.'); diff --git a/tests/Unit/Gateway/Bedrock/BedrockExceptionTest.php b/tests/Unit/Gateway/Bedrock/BedrockExceptionTest.php index 5a50be008..4fc986d3e 100644 --- a/tests/Unit/Gateway/Bedrock/BedrockExceptionTest.php +++ b/tests/Unit/Gateway/Bedrock/BedrockExceptionTest.php @@ -19,19 +19,19 @@ public function __construct( Exception::__construct($message, $httpStatus); } - public function getAwsErrorCode(): ?string + public function getAwsErrorCode(): string { return $this->awsErrorCode; } - public function getStatusCode(): ?int + public function getStatusCode(): int { return $this->httpStatus; } }; } -test('throttling maps to rate limited exception', function () { +test('throttling maps to rate limited exception', function (): void { $bedrock = mockBedrockException('ThrottlingException', 429); $aiException = BedrockException::toAiException($bedrock, 'bedrock', 'claude-sonnet'); @@ -41,7 +41,7 @@ public function getStatusCode(): ?int ->and($aiException->getPrevious())->toBe($bedrock); }); -test('service unavailable maps to provider overloaded', function () { +test('service unavailable maps to provider overloaded', function (): void { $bedrock = mockBedrockException('ServiceUnavailableException', 503); $aiException = BedrockException::toAiException($bedrock, 'bedrock', 'claude-sonnet'); @@ -50,7 +50,7 @@ public function getStatusCode(): ?int ->and($aiException->getCode())->toBe(503); }); -test('model not ready maps to provider overloaded', function () { +test('model not ready maps to provider overloaded', function (): void { $bedrock = mockBedrockException('ModelNotReadyException', 503); $aiException = BedrockException::toAiException($bedrock, 'bedrock', 'claude-sonnet'); @@ -58,7 +58,7 @@ public function getStatusCode(): ?int expect($aiException)->toBeInstanceOf(ProviderOverloadedException::class); }); -test('model timeout maps to provider overloaded', function () { +test('model timeout maps to provider overloaded', function (): void { $bedrock = mockBedrockException('ModelTimeoutException', 408); $aiException = BedrockException::toAiException($bedrock, 'bedrock', 'claude-sonnet'); @@ -66,7 +66,7 @@ public function getStatusCode(): ?int expect($aiException)->toBeInstanceOf(ProviderOverloadedException::class); }); -test('model stream error maps to provider overloaded', function () { +test('model stream error maps to provider overloaded', function (): void { $bedrock = mockBedrockException('ModelStreamErrorException', 424); $aiException = BedrockException::toAiException($bedrock, 'bedrock', 'claude-sonnet'); @@ -74,7 +74,7 @@ public function getStatusCode(): ?int expect($aiException)->toBeInstanceOf(ProviderOverloadedException::class); }); -test('internal server error maps to provider overloaded', function () { +test('internal server error maps to provider overloaded', function (): void { $bedrock = mockBedrockException('InternalServerException', 500); $aiException = BedrockException::toAiException($bedrock, 'bedrock', 'claude-sonnet'); @@ -82,7 +82,7 @@ public function getStatusCode(): ?int expect($aiException)->toBeInstanceOf(ProviderOverloadedException::class); }); -test('service quota exceeded maps to insufficient credits', function () { +test('service quota exceeded maps to insufficient credits', function (): void { $bedrock = mockBedrockException('ServiceQuotaExceededException', 402); $aiException = BedrockException::toAiException($bedrock, 'bedrock', 'claude-sonnet'); @@ -91,7 +91,7 @@ public function getStatusCode(): ?int ->and($aiException->getCode())->toBe(402); }); -test('unknown bedrock error falls through to generic ai exception', function () { +test('unknown bedrock error falls through to generic ai exception', function (): void { $bedrock = mockBedrockException('SomethingElseException', 400, 'weird error'); $aiException = BedrockException::toAiException($bedrock, 'bedrock', 'claude-sonnet'); @@ -102,7 +102,7 @@ public function getStatusCode(): ?int ->and($aiException->getMessage())->toContain('weird error'); }); -test('non-bedrock exception with credit balance message maps to insufficient credits', function () { +test('non-bedrock exception with credit balance message maps to insufficient credits', function (): void { $generic = new RuntimeException('Your credit balance is too low'); $aiException = BedrockException::toAiException($generic, 'bedrock', 'claude-sonnet'); @@ -110,7 +110,7 @@ public function getStatusCode(): ?int expect($aiException)->toBeInstanceOf(InsufficientCreditsException::class); }); -test('non-bedrock exception with quota exceeded message maps to insufficient credits', function () { +test('non-bedrock exception with quota exceeded message maps to insufficient credits', function (): void { $generic = new RuntimeException('You have exceeded your current quota'); $aiException = BedrockException::toAiException($generic, 'bedrock', 'claude-sonnet'); @@ -118,7 +118,7 @@ public function getStatusCode(): ?int expect($aiException)->toBeInstanceOf(InsufficientCreditsException::class); }); -test('non-bedrock generic exception wraps as ai exception', function () { +test('non-bedrock generic exception wraps as ai exception', function (): void { $generic = new RuntimeException('Some network failure', 500); $aiException = BedrockException::toAiException($generic, 'bedrock', 'claude-sonnet'); diff --git a/tests/Unit/Gateway/Bedrock/BedrockImageGatewayTest.php b/tests/Unit/Gateway/Bedrock/BedrockImageGatewayTest.php index 1910b22a5..e73a91c99 100644 --- a/tests/Unit/Gateway/Bedrock/BedrockImageGatewayTest.php +++ b/tests/Unit/Gateway/Bedrock/BedrockImageGatewayTest.php @@ -1,5 +1,6 @@ parseImageResponse($model, $result); } @@ -26,20 +27,20 @@ public function callParseSize(?string $size): array }; } -test('parse size converts 2:3 ratio to portrait dimensions', function () { +test('parse size converts 2:3 ratio to portrait dimensions', function (): void { expect(imageGateway()->callParseSize('2:3'))->toEqual([768, 1152]); }); -test('parse size converts 3:2 ratio to landscape dimensions', function () { +test('parse size converts 3:2 ratio to landscape dimensions', function (): void { expect(imageGateway()->callParseSize('3:2'))->toEqual([1152, 768]); }); -test('parse size defaults to square dimensions', function () { +test('parse size defaults to square dimensions', function (): void { expect(imageGateway()->callParseSize(null))->toEqual([1024, 1024]); expect(imageGateway()->callParseSize('1:1'))->toEqual([1024, 1024]); }); -test('stability body uses prompt and aspect ratio', function () { +test('stability body uses prompt and aspect ratio', function (): void { $body = imageGateway()->callPrepareBody('stability.sd3-5-large-v1:0', 'a cat', '2:3', 'standard'); expect($body)->toEqual([ @@ -49,7 +50,7 @@ public function callParseSize(?string $size): array ]); }); -test('stability body omits aspect ratio when size is null', function () { +test('stability body omits aspect ratio when size is null', function (): void { $body = imageGateway()->callPrepareBody('stability.stable-image-ultra-v1:0', 'a cat', null, 'standard'); expect($body)->toEqual([ @@ -58,7 +59,7 @@ public function callParseSize(?string $size): array ]); }); -test('titan image body uses text to image params', function () { +test('titan image body uses text to image params', function (): void { $body = imageGateway()->callPrepareBody('amazon.titan-image-generator-v1', 'a dog', '2:3', 'premium'); expect($body)->toEqual([ @@ -74,13 +75,13 @@ public function callParseSize(?string $size): array ]); }); -test('titan image body defaults quality to standard', function () { +test('titan image body defaults quality to standard', function (): void { $body = imageGateway()->callPrepareBody('amazon.titan-image-generator-v1', 'a dog', null, 'standard'); expect($body['imageGenerationConfig']['quality'])->toBe('standard'); }); -test('nova canvas body uses text to image params', function () { +test('nova canvas body uses text to image params', function (): void { $body = imageGateway()->callPrepareBody('amazon.nova-canvas-v1:0', 'a bird', '3:2', 'standard'); expect($body)->toEqual([ @@ -95,13 +96,13 @@ public function callParseSize(?string $size): array ]); }); -test('unknown model family falls back to plain prompt body', function () { +test('unknown model family falls back to plain prompt body', function (): void { $body = imageGateway()->callPrepareBody('unknown-model', 'something', '1:1', 'high'); expect($body)->toEqual(['prompt' => 'something']); }); -test('stability response is parsed from images array', function () { +test('stability response is parsed from images array', function (): void { $images = imageGateway()->callParseResponse('stability.sd3-5-large-v1:0', [ 'images' => ['sd-image-1', 'sd-image-2'], ]); @@ -112,7 +113,7 @@ public function callParseSize(?string $size): array ->and($images[1]->image)->toBe('sd-image-2'); }); -test('titan response is parsed from images array', function () { +test('titan response is parsed from images array', function (): void { $images = imageGateway()->callParseResponse('amazon.titan-image-generator-v1', [ 'images' => ['titan-image-1', 'titan-image-2'], ]); @@ -122,7 +123,7 @@ public function callParseSize(?string $size): array ->and($images[0]->mime)->toBe('image/png'); }); -test('nova canvas response is parsed from images array', function () { +test('nova canvas response is parsed from images array', function (): void { $images = imageGateway()->callParseResponse('amazon.nova-canvas-v1:0', [ 'images' => ['nova-image-1'], ]); @@ -131,7 +132,7 @@ public function callParseSize(?string $size): array ->and($images[0]->image)->toBe('nova-image-1'); }); -test('unknown model family returns empty collection', function () { +test('unknown model family returns empty collection', function (): void { $images = imageGateway()->callParseResponse('unknown-model', [ 'artifacts' => [['base64' => 'ignored']], 'images' => ['ignored'], @@ -140,7 +141,7 @@ public function callParseSize(?string $size): array expect($images)->toHaveCount(0); }); -test('missing images key yields empty collection', function () { +test('missing images key yields empty collection', function (): void { expect(imageGateway()->callParseResponse('stability.sd3-5-large-v1:0', [])->count())->toBe(0); expect(imageGateway()->callParseResponse('amazon.titan-image-v1', [])->count())->toBe(0); expect(imageGateway()->callParseResponse('amazon.nova-canvas-v1', [])->count())->toBe(0); diff --git a/tests/Unit/Gateway/Bedrock/BedrockTextGatewayTest.php b/tests/Unit/Gateway/Bedrock/BedrockTextGatewayTest.php index faaba2ebf..7973dd9f7 100644 --- a/tests/Unit/Gateway/Bedrock/BedrockTextGatewayTest.php +++ b/tests/Unit/Gateway/Bedrock/BedrockTextGatewayTest.php @@ -105,7 +105,7 @@ public function callFormatUserMessage(UserMessage $message): array return $this->formatUserMessage($message); } - public function callGetImageFormat(Image $image) + public function callGetImageFormat(Image $image): string { return $this->getImageFormat($image); } @@ -130,7 +130,7 @@ public function schema(JsonSchema $schema): array } } -test('user message is formatted with text block', function () { +test('user message is formatted with text block', function (): void { $formatted = textGateway()->callFormatMessages([ new UserMessage('hello'), ]); @@ -140,7 +140,7 @@ public function schema(JsonSchema $schema): array ]); }); -test('assistant message is formatted with text block', function () { +test('assistant message is formatted with text block', function (): void { $formatted = textGateway()->callFormatMessages([ new AssistantMessage('hi there'), ]); @@ -150,7 +150,7 @@ public function schema(JsonSchema $schema): array ]); }); -test('assistant message with tool calls is formatted with tool use blocks', function () { +test('assistant message with tool calls is formatted with tool use blocks', function (): void { $assistant = new AssistantMessage('calling tool', new Collection([ new ToolCall('tool-1', 'RandomGenerator', ['n' => 5]), ])); @@ -170,7 +170,7 @@ public function schema(JsonSchema $schema): array ]); }); -test('assistant message with empty tool input is formatted as a json object', function () { +test('assistant message with empty tool input is formatted as a json object', function (): void { $assistant = new AssistantMessage('', new Collection([ new ToolCall('tool-1', 'NoArgGenerator', []), ])); @@ -182,7 +182,7 @@ public function schema(JsonSchema $schema): array ->and(json_encode($input))->toBe('{}'); }); -test('assistant message with providerContentBlocks is formatted verbatim', function () { +test('assistant message with providerContentBlocks is formatted verbatim', function (): void { $message = new AssistantMessage( 'Hello', new Collection([new ToolCall('tool-1', 'doIt', [])]), @@ -205,7 +205,7 @@ public function schema(JsonSchema $schema): array ->and(json_encode($content[2]['toolUse']['input']))->toBe('{}'); }); -test('tool result message is formatted with tool result blocks', function () { +test('tool result message is formatted with tool result blocks', function (): void { $message = new ToolResultMessage(new Collection([ new ToolResult('tool-1', 'RandomGenerator', [], 'the result'), ])); @@ -223,7 +223,7 @@ public function schema(JsonSchema $schema): array ]); }); -test('tool result non-string results are json encoded', function () { +test('tool result non-string results are json encoded', function (): void { $message = new ToolResultMessage(new Collection([ new ToolResult('tool-1', 'RandomGenerator', [], ['answer' => 42]), ])); @@ -234,7 +234,7 @@ public function schema(JsonSchema $schema): array ->toBe('{"answer":42}'); }); -test('generic non assistant message falls back to user role', function () { +test('generic non assistant message falls back to user role', function (): void { $formatted = textGateway()->callFormatMessages([ new Message('tool_result', 'tool output'), ]); @@ -245,7 +245,7 @@ public function schema(JsonSchema $schema): array ]); }); -test('array shaped message is formatted', function () { +test('array shaped message is formatted', function (): void { $formatted = textGateway()->callFormatMessages([ ['role' => 'assistant', 'content' => 'hello'], ]); @@ -256,7 +256,7 @@ public function schema(JsonSchema $schema): array ]); }); -test('user message with pdf document attachment produces document block', function () { +test('user message with pdf document attachment produces document block', function (): void { $user = new UserMessage('here', [new Base64Document(base64_encode('pdf-bytes'), 'application/pdf')]); $formatted = textGateway()->callFormatUserMessage($user); @@ -268,7 +268,7 @@ public function schema(JsonSchema $schema): array ->and($formatted['content'][1]['document']['source']['bytes'])->toBe('pdf-bytes'); }); -test('s3 document attachment is sent as s3Location reference', function () { +test('s3 document attachment is sent as s3Location reference', function (): void { $document = (new S3Document('s3://my-bucket/path/report.pdf', null, 'application/pdf'))->as('report'); $user = new UserMessage('summarize this', [$document]); @@ -281,7 +281,7 @@ public function schema(JsonSchema $schema): array ]); }); -test('s3 document attachment includes bucketOwner when set', function () { +test('s3 document attachment includes bucketOwner when set', function (): void { $document = (new S3Document('s3://my-bucket/path/report.pdf', '123456789012', 'application/pdf'))->as('report'); $user = new UserMessage('summarize this', [$document]); @@ -295,20 +295,20 @@ public function schema(JsonSchema $schema): array ]); }); -test('s3 document can be created with constructor from s3 url', function () { +test('s3 document can be created with constructor from s3 url', function (): void { $document = new S3Document('s3://my-bucket/path/report.pdf'); expect($document)->toBeInstanceOf(S3Document::class) ->and($document->url)->toBe('s3://my-bucket/path/report.pdf'); }); -test('s3 document content throws unsupported exception', function () { +test('s3 document content throws unsupported exception', function (): void { $document = new S3Document('s3://my-bucket/path/report.pdf'); $document->content(); })->throws(InvalidArgumentException::class); -test('document format maps common mime types', function () { +test('document format maps common mime types', function (): void { $gateway = textGateway(); expect($gateway->callGetDocumentFormat(new Base64Document(base64_encode('doc-bytes'), 'application/pdf')))->toBe('pdf'); @@ -321,10 +321,10 @@ public function schema(JsonSchema $schema): array expect($gateway->callGetDocumentFormat(new Base64Document(base64_encode('doc-bytes'), 'text/markdown')))->toBe('md'); expect($gateway->callGetDocumentFormat(new Base64Document(base64_encode('doc-bytes'), 'text/x-markdown')))->toBe('md'); expect($gateway->callGetDocumentFormat(new Base64Document(base64_encode('doc-bytes'), 'text/plain; charset=utf-8')))->toBe('txt'); - expect($gateway->callGetDocumentFormat(new Base64Document(base64_encode('doc-bytes'), null)))->toBeNull(); + expect($gateway->callGetDocumentFormat(new Base64Document(base64_encode('doc-bytes'))))->toBeNull(); }); -test('user message with base64 image attachment produces image block', function () { +test('user message with base64 image attachment produces image block', function (): void { $user = new UserMessage('see this', [new Base64Image(base64_encode('image-bytes'), 'image/png')]); $formatted = textGateway()->callFormatUserMessage($user); @@ -339,7 +339,7 @@ public function schema(JsonSchema $schema): array ]); }); -test('local image attachment is read from disk into bytes', function () { +test('local image attachment is read from disk into bytes', function (): void { $tmp = tempnam(sys_get_temp_dir(), 'bedrock-image-'); file_put_contents($tmp, 'local-image-bytes'); @@ -359,25 +359,25 @@ public function schema(JsonSchema $schema): array } }); -test('remote image attachment is rejected', function () { +test('remote image attachment is rejected', function (): void { expect(fn () => textGateway()->callMapAttachments(new Collection([ new RemoteImage('https://example.com/cat.png', 'image/png'), ])))->toThrow(InvalidArgumentException::class, 'Remote attachments are not supported by Bedrock'); }); -test('provider image attachment is rejected', function () { +test('provider image attachment is rejected', function (): void { expect(fn () => textGateway()->callMapAttachments(new Collection([ new ProviderImage('img_123'), ])))->toThrow(InvalidArgumentException::class, 'Provider-stored attachments are not supported by Bedrock'); }); -test('uploaded file attachment is rejected as unsupported', function () { +test('uploaded file attachment is rejected as unsupported', function (): void { expect(fn () => textGateway()->callMapAttachments(new Collection([ TestingFile::create('cat.png'), ])))->toThrow(InvalidArgumentException::class, 'Unsupported attachment type'); }); -test('image format maps common image mime types', function () { +test('image format maps common image mime types', function (): void { $gateway = textGateway(); expect($gateway->callGetImageFormat(new Base64Image(base64_encode('image-bytes'), 'image/png')))->toBe('png'); @@ -387,17 +387,17 @@ public function schema(JsonSchema $schema): array expect($gateway->callGetImageFormat(new Base64Image(base64_encode('image-bytes'), 'image/webp')))->toBe('webp'); }); -test('image format throws when mime type is unsupported', function () { +test('image format throws when mime type is unsupported', function (): void { expect(fn () => textGateway()->callGetImageFormat(new Base64Image(base64_encode('image-bytes'), 'image/unsupported'))) ->toThrow(InvalidArgumentException::class, 'Unsupported image MIME type [image/unsupported]'); }); -test('image format throws when mime type is missing', function () { +test('image format throws when mime type is missing', function (): void { expect(fn () => textGateway()->callGetImageFormat(new Base64Image(base64_encode('image-bytes'), null))) ->toThrow(InvalidArgumentException::class, 'Unable to determine MIME type'); }); -test('format tools produces converse tool specs', function () { +test('format tools produces converse tool specs', function (): void { $tools = [new BedrockSampleTool]; $formatted = textGateway()->callFormatTools($tools); @@ -408,20 +408,20 @@ public function schema(JsonSchema $schema): array ->and($formatted[0]['toolSpec']['inputSchema']['json'])->toBeArray(); }); -test('format tools uses a tool name() method when present', function () { +test('format tools uses a tool name() method when present', function (): void { $formatted = textGateway()->callFormatTools([new NamedTool('aliased_tool')]); expect($formatted)->toHaveCount(1) ->and($formatted[0]['toolSpec']['name'])->toBe('aliased_tool'); }); -test('format tools ignores non tool values', function () { +test('format tools ignores non tool values', function (): void { $formatted = textGateway()->callFormatTools([new BedrockSampleTool, 'not-a-tool']); expect($formatted)->toHaveCount(1); }); -test('build schema tools prepends structured output tool', function () { +test('build schema tools prepends structured output tool', function (): void { $schemaTools = textGateway()->callBuildSchemaTools([], [new BedrockSampleTool]); expect($schemaTools)->toHaveCount(2) @@ -430,18 +430,18 @@ public function schema(JsonSchema $schema): array ->and($schemaTools[1]['toolSpec']['name'])->toBe('BedrockSampleTool'); }); -test('build tool config returns null when no tools present', function () { +test('build tool config returns null when no tools present', function (): void { expect(textGateway()->callBuildToolConfig(null, null, true, false))->toBeNull(); }); -test('build tool config returns formatted tools when no schema present', function () { +test('build tool config returns formatted tools when no schema present', function (): void { $formatted = [['toolSpec' => ['name' => 'X']]]; expect(textGateway()->callBuildToolConfig(null, $formatted, false, false)) ->toEqual(['tools' => $formatted]); }); -test('build tool config uses auto tool choice on non final schema step', function () { +test('build tool config uses auto tool choice on non final schema step', function (): void { $schemaTools = [['toolSpec' => ['name' => 'structured_output']]]; $config = textGateway()->callBuildToolConfig($schemaTools, null, false, false); @@ -450,7 +450,7 @@ public function schema(JsonSchema $schema): array ->and($config['toolChoice'])->toHaveKey('auto'); }); -test('build tool config forces structured tool on final schema step', function () { +test('build tool config forces structured tool on final schema step', function (): void { $schemaTools = [['toolSpec' => ['name' => 'structured_output']]]; $config = textGateway()->callBuildToolConfig($schemaTools, null, false, true); @@ -458,7 +458,7 @@ public function schema(JsonSchema $schema): array expect($config['toolChoice'])->toEqual(['tool' => ['name' => 'structured_output']]); }); -test('build tool config forces structured tool when no real tools provided', function () { +test('build tool config forces structured tool when no real tools provided', function (): void { $schemaTools = [['toolSpec' => ['name' => 'structured_output']]]; $config = textGateway()->callBuildToolConfig($schemaTools, null, true, false); @@ -466,11 +466,11 @@ public function schema(JsonSchema $schema): array expect($config['toolChoice'])->toEqual(['tool' => ['name' => 'structured_output']]); }); -test('build inference config is empty without options', function () { +test('build inference config is empty without options', function (): void { expect(textGateway()->callBuildInferenceConfig(null))->toEqual([]); }); -test('build inference config maps max tokens and temperature', function () { +test('build inference config maps max tokens and temperature', function (): void { $options = new TextGenerationOptions(maxTokens: 500, temperature: 0.7); expect(textGateway()->callBuildInferenceConfig($options))->toEqual([ @@ -479,7 +479,7 @@ public function schema(JsonSchema $schema): array ]); }); -test('build inference config includes temperature of zero', function () { +test('build inference config includes temperature of zero', function (): void { $options = new TextGenerationOptions(temperature: 0.0); expect(textGateway()->callBuildInferenceConfig($options))->toEqual([ @@ -487,7 +487,7 @@ public function schema(JsonSchema $schema): array ]); }); -test('build assistant conversation message omits text block when empty', function () { +test('build assistant conversation message omits text block when empty', function (): void { $message = textGateway()->callBuildAssistantConversationMessage('', [ new ToolCall('t-1', 'X', []), ]); @@ -496,7 +496,7 @@ public function schema(JsonSchema $schema): array ->and($message['content'][0]['toolUse']['name'])->toBe('X'); }); -test('build assistant conversation message includes text and tool calls', function () { +test('build assistant conversation message includes text and tool calls', function (): void { $message = textGateway()->callBuildAssistantConversationMessage('thinking', [ new ToolCall('t-1', 'X', ['a' => 1]), ]); @@ -507,7 +507,7 @@ public function schema(JsonSchema $schema): array ]); }); -test('build tool result conversation message uses array form', function () { +test('build tool result conversation message uses array form', function (): void { $message = textGateway()->callBuildToolResultConversationMessage([ new ToolResult('t-1', 'X', [], ['out' => true]), ]); @@ -523,23 +523,23 @@ public function schema(JsonSchema $schema): array ]); }); -test('resolve max steps returns one when no tools', function () { +test('resolve max steps returns one when no tools', function (): void { expect(textGateway()->callResolveMaxSteps([], null))->toBe(1); }); -test('resolve max steps honors explicit option', function () { +test('resolve max steps honors explicit option', function (): void { $options = new TextGenerationOptions(maxSteps: 10); expect(textGateway()->callResolveMaxSteps([new BedrockSampleTool], $options))->toBe(10); }); -test('resolve max steps falls back to tool count times one and a half', function () { +test('resolve max steps falls back to tool count times one and a half', function (): void { $tools = [new BedrockSampleTool, new BedrockSampleTool]; expect(textGateway()->callResolveMaxSteps($tools, null))->toBe(3); }); -test('build converse parameters attaches system instructions', function () { +test('build converse parameters attaches system instructions', function (): void { $params = textGateway()->callBuildConverseParameters( 'claude-sonnet', 'you are helpful', @@ -558,7 +558,7 @@ public function schema(JsonSchema $schema): array ->and($params)->not->toHaveKey('inferenceConfig'); }); -test('build converse parameters includes tool config and inference config when present', function () { +test('build converse parameters includes tool config and inference config when present', function (): void { $formattedTools = [['toolSpec' => ['name' => 'X']]]; $options = new TextGenerationOptions(maxTokens: 100); @@ -578,7 +578,7 @@ public function schema(JsonSchema $schema): array ->and($params['inferenceConfig'])->toEqual(['maxTokens' => 100]); }); -test('build converse parameters flat-merges agent provider options for bedrock', function () { +test('build converse parameters flat-merges agent provider options for bedrock', function (): void { $options = TextGenerationOptions::forAgent(new ProviderOptionsAgent); $params = textGateway()->callBuildConverseParameters( @@ -605,7 +605,7 @@ public function schema(JsonSchema $schema): array ]); }); -test('build converse parameters omits provider options when agent has none', function () { +test('build converse parameters omits provider options when agent has none', function (): void { $params = textGateway()->callBuildConverseParameters( 'claude-sonnet', null, diff --git a/tests/Unit/Gateway/Bedrock/CreatesBedrockClientTest.php b/tests/Unit/Gateway/Bedrock/CreatesBedrockClientTest.php index 0c549f722..809dd8f18 100644 --- a/tests/Unit/Gateway/Bedrock/CreatesBedrockClientTest.php +++ b/tests/Unit/Gateway/Bedrock/CreatesBedrockClientTest.php @@ -15,7 +15,7 @@ public function resolve(array $credentials, array $config): array }; } -test('bearer token credentials use http bearer auth scheme', function () { +test('bearer token credentials use http bearer auth scheme', function (): void { $config = bedrockClientTrait()->resolve( ['key' => 'bedrock-token'], [], @@ -27,7 +27,7 @@ public function resolve(array $credentials, array $config): array ]); }); -test('bearer token takes priority over iam credentials', function () { +test('bearer token takes priority over iam credentials', function (): void { $config = bedrockClientTrait()->resolve( [ 'key' => 'bedrock-token', @@ -41,7 +41,7 @@ public function resolve(array $credentials, array $config): array ->and($config)->not->toHaveKey('credentials'); }); -test('iam credentials produce key and secret', function () { +test('iam credentials produce key and secret', function (): void { $config = bedrockClientTrait()->resolve( [ 'access_key_id' => 'AKIA123', @@ -58,7 +58,7 @@ public function resolve(array $credentials, array $config): array ]); }); -test('session token is included when provided with iam credentials', function () { +test('session token is included when provided with iam credentials', function (): void { $config = bedrockClientTrait()->resolve( [ 'access_key_id' => 'AKIA123', @@ -75,13 +75,13 @@ public function resolve(array $credentials, array $config): array ]); }); -test('empty credentials with default provider enabled returns empty config', function () { +test('empty credentials with default provider enabled returns empty config', function (): void { $config = bedrockClientTrait()->resolve([], []); expect($config)->toEqual([]); }); -test('empty credentials with default provider disabled returns false credentials', function () { +test('empty credentials with default provider disabled returns false credentials', function (): void { $config = bedrockClientTrait()->resolve( [], ['use_default_credential_provider' => false], @@ -90,7 +90,7 @@ public function resolve(array $credentials, array $config): array expect($config)->toEqual(['credentials' => false]); }); -test('partial iam credentials fall back to default provider', function () { +test('partial iam credentials fall back to default provider', function (): void { $config = bedrockClientTrait()->resolve( ['access_key_id' => 'AKIA123'], [], diff --git a/tests/Unit/Gateway/Concerns/ComposesSchemaInstructionsTest.php b/tests/Unit/Gateway/Concerns/ComposesSchemaInstructionsTest.php index 3e4a175fe..8d1336a53 100644 --- a/tests/Unit/Gateway/Concerns/ComposesSchemaInstructionsTest.php +++ b/tests/Unit/Gateway/Concerns/ComposesSchemaInstructionsTest.php @@ -3,7 +3,7 @@ use Illuminate\JsonSchema\JsonSchemaTypeFactory; use Laravel\Ai\Gateway\Concerns\ComposesSchemaInstructions; -beforeEach(function () { +beforeEach(function (): void { $this->trait = new class { use ComposesSchemaInstructions { @@ -14,17 +14,17 @@ $this->factory = new JsonSchemaTypeFactory; }); -test('returns instructions unchanged when schema is blank', function () { +test('returns instructions unchanged when schema is blank', function (): void { expect($this->trait->composeInstructions('Be helpful.', null)) ->toBe('Be helpful.'); }); -test('returns null when both instructions and schema are blank', function () { +test('returns null when both instructions and schema are blank', function (): void { expect($this->trait->composeInstructions(null, null)) ->toBeNull(); }); -test('appends schema instruction when instructions are blank', function () { +test('appends schema instruction when instructions are blank', function (): void { $schema = ['name' => $this->factory->string()->description('The name')]; $result = $this->trait->composeInstructions(null, $schema); @@ -32,7 +32,7 @@ expect($result)->toContain('JSON object'); }); -test('prepends instructions before schema instruction', function () { +test('prepends instructions before schema instruction', function (): void { $schema = ['name' => $this->factory->string()->description('The name')]; $result = $this->trait->composeInstructions('Be concise.', $schema); @@ -41,7 +41,7 @@ expect($result)->toContain('JSON object'); }); -test('non-ascii characters in schema descriptions are not unicode-escaped', function () { +test('non-ascii characters in schema descriptions are not unicode-escaped', function (): void { $schema = ['name' => $this->factory->string()->description('名前を入力してください')]; $result = $this->trait->composeInstructions(null, $schema); diff --git a/tests/Unit/Gateway/Concerns/DecodesStructuredOutputTest.php b/tests/Unit/Gateway/Concerns/DecodesStructuredOutputTest.php index 6a0fedab7..f70a919f7 100644 --- a/tests/Unit/Gateway/Concerns/DecodesStructuredOutputTest.php +++ b/tests/Unit/Gateway/Concerns/DecodesStructuredOutputTest.php @@ -15,14 +15,14 @@ public function decode(?string $text): array }; } -test('decodes plain JSON object', function () { +test('decodes plain JSON object', function (): void { $host = decoderHost(); expect($host->decode('{"name":"Ada","age":36}')) ->toBe(['name' => 'Ada', 'age' => 36]); }); -test('strips ```json fence and decodes', function () { +test('strips ```json fence and decodes', function (): void { $host = decoderHost(); $payload = "```json\n{\"ok\":true}\n```"; @@ -30,7 +30,7 @@ public function decode(?string $text): array expect($host->decode($payload))->toBe(['ok' => true]); }); -test('strips bare ``` fence and decodes', function () { +test('strips bare ``` fence and decodes', function (): void { $host = decoderHost(); $payload = "```\n{\"ok\":true}\n```"; @@ -38,7 +38,7 @@ public function decode(?string $text): array expect($host->decode($payload))->toBe(['ok' => true]); }); -test('tolerates leading and trailing whitespace around the fence', function () { +test('tolerates leading and trailing whitespace around the fence', function (): void { $host = decoderHost(); $payload = " \n\n```json\n{\"value\":42}\n``` \n"; @@ -46,7 +46,7 @@ public function decode(?string $text): array expect($host->decode($payload))->toBe(['value' => 42]); }); -test('handles uppercase JSON fence language tag', function () { +test('handles uppercase JSON fence language tag', function (): void { $host = decoderHost(); $payload = "```JSON\n{\"ok\":true}\n```"; @@ -54,21 +54,21 @@ public function decode(?string $text): array expect($host->decode($payload))->toBe(['ok' => true]); }); -test('handles mixed-case json fence language tag', function () { +test('handles mixed-case json fence language tag', function (): void { $host = decoderHost(); expect($host->decode("```Json\n{\"ok\":true}\n```"))->toBe(['ok' => true]) ->and($host->decode("```jSoN\n{\"ok\":true}\n```"))->toBe(['ok' => true]); }); -test('returns empty array for invalid JSON', function () { +test('returns empty array for invalid JSON', function (): void { $host = decoderHost(); expect($host->decode('not json at all'))->toBe([]) ->and($host->decode("```json\n{invalid}\n```"))->toBe([]); }); -test('returns empty array for null or empty input', function () { +test('returns empty array for null or empty input', function (): void { $host = decoderHost(); expect($host->decode(null))->toBe([]) @@ -76,7 +76,7 @@ public function decode(?string $text): array ->and($host->decode(' '))->toBe([]); }); -test('does not mangle JSON containing triple-backtick substrings inside string values', function () { +test('does not mangle JSON containing triple-backtick substrings inside string values', function (): void { $host = decoderHost(); $payload = '{"snippet":"```js\nconsole.log(1)\n```","ok":true}'; @@ -87,7 +87,7 @@ public function decode(?string $text): array ]); }); -test('returns empty array when JSON decodes to a non-array scalar', function () { +test('returns empty array when JSON decodes to a non-array scalar', function (): void { $host = decoderHost(); expect($host->decode('"just a string"'))->toBe([]) diff --git a/tests/Unit/Gateway/Concerns/HandlesFailoverErrorsTest.php b/tests/Unit/Gateway/Concerns/HandlesFailoverErrorsTest.php index 8b6d23b5e..9e8991078 100644 --- a/tests/Unit/Gateway/Concerns/HandlesFailoverErrorsTest.php +++ b/tests/Unit/Gateway/Concerns/HandlesFailoverErrorsTest.php @@ -8,7 +8,7 @@ use Laravel\Ai\Exceptions\RateLimitedException; use Laravel\Ai\Gateway\Concerns\HandlesFailoverErrors; -beforeEach(function () { +beforeEach(function (): void { $this->gateway = new class { use HandlesFailoverErrors { @@ -34,7 +34,7 @@ function failoverableException(int $status, array $body = []): RequestException return new RequestException(new Response($psr)); } -test('overriding overloadedStatusCodes replaces the default 503', function () { +test('overriding overloadedStatusCodes replaces the default 503', function (): void { expect(fn () => $this->gateway->withErrorHandling( 'anthropic', fn () => throw failoverableException(529), @@ -46,7 +46,7 @@ function failoverableException(int $status, array $body = []): RequestException ))->toThrow(RequestException::class); }); -test('429 takes precedence over insufficient credit pattern matching', function () { +test('429 takes precedence over insufficient credit pattern matching', function (): void { expect(fn () => $this->gateway->withErrorHandling( 'anthropic', fn () => throw failoverableException(429, [ @@ -55,7 +55,7 @@ function failoverableException(int $status, array $body = []): RequestException ))->toThrow(RateLimitedException::class); }); -test('402 throws InsufficientCreditsException without requiring patterns', function () { +test('402 throws InsufficientCreditsException without requiring patterns', function (): void { $gateway = new class { use HandlesFailoverErrors { @@ -63,7 +63,7 @@ function failoverableException(int $status, array $body = []): RequestException } }; - expect(fn () => $gateway->withErrorHandling( + expect(fn (): mixed => $gateway->withErrorHandling( 'deepseek', fn () => throw failoverableException(402, [ 'error' => ['message' => 'Insufficient Balance'], @@ -71,7 +71,7 @@ function failoverableException(int $status, array $body = []): RequestException ))->toThrow(InsufficientCreditsException::class); }); -test('non-matching message is rethrown as the original RequestException', function () { +test('non-matching message is rethrown as the original RequestException', function (): void { expect(fn () => $this->gateway->withErrorHandling( 'anthropic', fn () => throw failoverableException(400, [ diff --git a/tests/Unit/Gateway/Concerns/ParsesServerSentEventsTest.php b/tests/Unit/Gateway/Concerns/ParsesServerSentEventsTest.php index 712caa9ba..a928ba033 100644 --- a/tests/Unit/Gateway/Concerns/ParsesServerSentEventsTest.php +++ b/tests/Unit/Gateway/Concerns/ParsesServerSentEventsTest.php @@ -15,7 +15,7 @@ public function parse($streamBody): Generator }; } -test('reads stream byte by byte to prevent event batching', function () { +test('reads stream byte by byte to prevent event batching', function (): void { $payload = implode("\n\n", [ 'data: {"type":"message_start"}', 'data: {"type":"content_block_delta","delta":{"text":"Hello"}}', @@ -30,7 +30,7 @@ public function parse($streamBody): Generator expect($stream->maxReadSize)->toBe(1, 'Parser must read byte-by-byte to prevent SSE event batching.'); }); -test('yields each event before reading next events data', function () { +test('yields each event before reading next events data', function (): void { $payload = "data: {\"type\":\"event_1\"}\n\ndata: {\"type\":\"event_2\"}\n\ndata: {\"type\":\"event_3\"}\n\n"; $stream = new FakeStream($payload); diff --git a/tests/Unit/Gateway/Concerns/ResolvesToolNameTest.php b/tests/Unit/Gateway/Concerns/ResolvesToolNameTest.php index 63b83c435..eec82a5e7 100644 --- a/tests/Unit/Gateway/Concerns/ResolvesToolNameTest.php +++ b/tests/Unit/Gateway/Concerns/ResolvesToolNameTest.php @@ -54,24 +54,24 @@ public function callFind(string $name, array $tools): ?Tool }; } -test('resolveToolName falls back to class basename when tool has no name() method', function () { +test('resolveToolName falls back to class basename when tool has no name() method', function (): void { $host = resolverHost(); expect($host->callResolve(new FixedNumberGenerator))->toBe('FixedNumberGenerator'); }); -test('resolveToolName prefers the declared name() method when present', function () { +test('resolveToolName prefers the declared name() method when present', function (): void { $host = resolverHost(); expect($host->callResolve(new NamedTool('aliased_tool'))) ->toBe('aliased_tool'); }); -test('resolveToolName falls back when name() is not callable', function () { +test('resolveToolName falls back when name() is not callable', function (): void { expect(ToolNameResolver::resolve(new ProtectedNameTool))->toBe('ProtectedNameTool'); }); -test('findTool matches a tool by its declared name() when multiple share a class', function () { +test('findTool matches a tool by its declared name() when multiple share a class', function (): void { $host = resolverHost(); $tools = [ @@ -85,7 +85,7 @@ public function callFind(string $name, array $tools): ?Tool ->and($host->callFind('unknown', $tools))->toBeNull(); }); -test('findTool returns null when no tool matches', function () { +test('findTool returns null when no tool matches', function (): void { $host = resolverHost(); expect($host->callFind('missing', [new FixedNumberGenerator]))->toBeNull(); diff --git a/tests/Unit/Gateway/Groq/ToolMappingTest.php b/tests/Unit/Gateway/Groq/ToolMappingTest.php index 353270549..c0a6d35b7 100644 --- a/tests/Unit/Gateway/Groq/ToolMappingTest.php +++ b/tests/Unit/Gateway/Groq/ToolMappingTest.php @@ -5,14 +5,14 @@ use Laravel\Ai\Gateway\OpenAiCompatible\Concerns\MapsChatCompletionTools; use Laravel\Ai\Tools\Request; -test('tool parameters are not wrapped in schema definition', function () { +test('tool parameters are not wrapped in schema definition', function (): void { $mapper = new class { use MapsChatCompletionTools; public function map(array $tools): array { - return array_map(fn ($tool) => $this->mapTool($tool), $tools); + return array_map($this->mapTool(...), $tools); } }; @@ -51,14 +51,14 @@ public function schema(JsonSchema $schema): array ->and($parameters['additionalProperties'])->toBeFalse(); }); -test('tool with empty schema includes parameters', function () { +test('tool with empty schema includes parameters', function (): void { $mapper = new class { use MapsChatCompletionTools; public function map(array $tools): array { - return array_map(fn ($tool) => $this->mapTool($tool), $tools); + return array_map($this->mapTool(...), $tools); } }; diff --git a/tests/Unit/Gateway/InvokesToolsTest.php b/tests/Unit/Gateway/InvokesToolsTest.php index 89a69a99f..bb2f018c3 100644 --- a/tests/Unit/Gateway/InvokesToolsTest.php +++ b/tests/Unit/Gateway/InvokesToolsTest.php @@ -6,7 +6,7 @@ use Laravel\Ai\Gateway\Concerns\InvokesTools; use Laravel\Ai\Tools\Request; -test('tool invocation callbacks are restored after nested tool invocations', function () { +test('tool invocation callbacks are restored after nested tool invocations', function (): void { $events = []; $gateway = new class @@ -46,23 +46,23 @@ public function schema(JsonSchema $schema): array }; $gateway->onToolInvocation( - invoking: function (Tool $tool) use (&$events) { - $events[] = 'parent invoking '.((string) $tool->description()); + invoking: function (Tool $tool) use (&$events): void { + $events[] = 'parent invoking '.($tool->description()); }, - invoked: function (Tool $tool, array $arguments, mixed $result) use (&$events) { - $events[] = 'parent invoked '.((string) $tool->description()).':'.$result; + invoked: function (Tool $tool, array $arguments, mixed $result) use (&$events): void { + $events[] = 'parent invoked '.($tool->description()).':'.$result; }, ); - $nestedTool = $makeTool('nested', fn () => 'nested result'); + $nestedTool = $makeTool('nested', fn (): string => 'nested result'); - $delegatingTool = $makeTool('delegating', function () use ($gateway, $nestedTool, &$events) { + $delegatingTool = $makeTool('delegating', function () use ($gateway, $nestedTool, &$events): string { $gateway->onToolInvocation( - invoking: function (Tool $tool) use (&$events) { - $events[] = 'sub invoking '.((string) $tool->description()); + invoking: function (Tool $tool) use (&$events): void { + $events[] = 'sub invoking '.($tool->description()); }, - invoked: function (Tool $tool, array $arguments, mixed $result) use (&$events) { - $events[] = 'sub invoked '.((string) $tool->description()).':'.$result; + invoked: function (Tool $tool, array $arguments, mixed $result) use (&$events): void { + $events[] = 'sub invoked '.($tool->description()).':'.$result; }, ); @@ -71,7 +71,7 @@ public function schema(JsonSchema $schema): array return 'delegated result'; }); - $siblingTool = $makeTool('sibling', fn () => 'sibling result'); + $siblingTool = $makeTool('sibling', fn (): string => 'sibling result'); $gateway->invoke($delegatingTool); $gateway->invoke($siblingTool); diff --git a/tests/Unit/Gateway/OpenAi/AudioFilenameTest.php b/tests/Unit/Gateway/OpenAi/AudioFilenameTest.php index 196fdcf09..759305bd9 100644 --- a/tests/Unit/Gateway/OpenAi/AudioFilenameTest.php +++ b/tests/Unit/Gateway/OpenAi/AudioFilenameTest.php @@ -9,23 +9,23 @@ function callAudioFilename(TranscribableAudio $audio): string { $dispatcher = new class implements Dispatcher { - public function listen($events, $listener = null) {} + public function listen($events, $listener = null): void {} - public function hasListeners($eventName) {} + public function hasListeners($eventName): void {} - public function subscribe($subscriber) {} + public function subscribe($subscriber): void {} - public function until($event, $payload = []) {} + public function until($event, $payload = []): void {} - public function dispatch($event, $payload = [], $halt = false) {} + public function dispatch($event, $payload = [], $halt = false): void {} - public function push($event, $payload = []) {} + public function push($event, $payload = []): void {} - public function flush($event) {} + public function flush($event): void {} - public function forget($event) {} + public function forget($event): void {} - public function forgetPushed() {} + public function forgetPushed(): void {} }; $gateway = new OpenAiGateway($dispatcher); @@ -35,55 +35,55 @@ public function forgetPushed() {} return $method->invoke($gateway, $audio); } -test('webm audio gets webm extension', function () { +test('webm audio gets webm extension', function (): void { $audio = new Base64Audio(base64_encode('fake'), 'audio/webm'); expect(callAudioFilename($audio))->toEqual('audio.webm'); }); -test('ogg audio gets ogg extension', function () { +test('ogg audio gets ogg extension', function (): void { $audio = new Base64Audio(base64_encode('fake'), 'audio/ogg'); expect(callAudioFilename($audio))->toEqual('audio.ogg'); }); -test('wav audio gets wav extension', function () { +test('wav audio gets wav extension', function (): void { $audio = new Base64Audio(base64_encode('fake'), 'audio/wav'); expect(callAudioFilename($audio))->toEqual('audio.wav'); }); -test('m4a audio gets m4a extension', function () { +test('m4a audio gets m4a extension', function (): void { $audio = new Base64Audio(base64_encode('fake'), 'audio/m4a'); expect(callAudioFilename($audio))->toEqual('audio.m4a'); }); -test('flac audio gets flac extension', function () { +test('flac audio gets flac extension', function (): void { $audio = new Base64Audio(base64_encode('fake'), 'audio/flac'); expect(callAudioFilename($audio))->toEqual('audio.flac'); }); -test('mp3 audio gets mp3 extension', function () { +test('mp3 audio gets mp3 extension', function (): void { $audio = new Base64Audio(base64_encode('fake'), 'audio/mpeg'); expect(callAudioFilename($audio))->toEqual('audio.mp3'); }); -test('unknown mime defaults to mp3', function () { +test('unknown mime defaults to mp3', function (): void { $audio = new Base64Audio(base64_encode('fake'), 'audio/unknown'); expect(callAudioFilename($audio))->toEqual('audio.mp3'); }); -test('null mime defaults to mp3', function () { - $audio = new Base64Audio(base64_encode('fake'), null); +test('null mime defaults to mp3', function (): void { + $audio = new Base64Audio(base64_encode('fake')); expect(callAudioFilename($audio))->toEqual('audio.mp3'); }); -test('custom name via as method is respected', function () { +test('custom name via as method is respected', function (): void { $audio = (new Base64Audio(base64_encode('fake'), 'audio/webm'))->as('my-recording.webm'); expect(callAudioFilename($audio))->toEqual('my-recording.webm'); diff --git a/tests/Unit/Gateway/TextGenerationLoopTest.php b/tests/Unit/Gateway/TextGenerationLoopTest.php index 543f9c83f..7dd659d5d 100644 --- a/tests/Unit/Gateway/TextGenerationLoopTest.php +++ b/tests/Unit/Gateway/TextGenerationLoopTest.php @@ -13,6 +13,7 @@ use Laravel\Ai\Responses\Data\Meta; use Laravel\Ai\Responses\Data\ToolCall; use Laravel\Ai\Responses\Data\Usage; +use Laravel\Ai\Responses\TextResponse; use Laravel\Ai\Streaming\Events\Error; use Laravel\Ai\Streaming\Events\StreamEnd; use Laravel\Ai\Streaming\Events\TextDelta; @@ -20,7 +21,7 @@ use Laravel\Ai\Streaming\Events\ToolResult as ToolResultEvent; use Laravel\Ai\Tools\Request; -test('it does not execute tool calls on the final generation step', function () { +test('it does not execute tool calls on the final generation step', function (): void { $tool = new TextGenerationLoopCountingTool; $gateway = new TextGenerationLoopFakeGateway([ new StepResponse( @@ -41,7 +42,6 @@ [$tool], null, new TextGenerationOptions(maxSteps: 1), - null, ); expect($tool->calls)->toBe(0) @@ -53,7 +53,7 @@ ->and($response->steps->first()->toolResults)->toBe([]); }); -test('it holds stream end until the streamed tool loop is complete', function () { +test('it holds stream end until the streamed tool loop is complete', function (): void { $tool = new TextGenerationLoopCountingTool; $firstToolCall = new ToolCall('call-1', TextGenerationLoopCountingTool::class, [], 'call-1'); $gateway = new TextGenerationLoopFakeGateway(streams: [ @@ -76,7 +76,6 @@ [$tool], null, new TextGenerationOptions(maxSteps: 2), - null, )); $streamEnds = collect($events)->whereInstanceOf(StreamEnd::class); @@ -90,7 +89,7 @@ ->and($streamEnds->first()->usage->completionTokens)->toBe(3); }); -test('it does not execute streamed tool calls on the final step', function () { +test('it does not execute streamed tool calls on the final step', function (): void { $tool = new TextGenerationLoopCountingTool; $toolCall = new ToolCall('call-1', TextGenerationLoopCountingTool::class, [], 'call-1'); $gateway = new TextGenerationLoopFakeGateway(streams: [ @@ -109,7 +108,6 @@ [$tool], null, new TextGenerationOptions(maxSteps: 1), - null, )); expect($tool->calls)->toBe(0) @@ -118,7 +116,7 @@ ->and(collect($events)->whereInstanceOf(StreamEnd::class))->toHaveCount(1); }); -test('it clamps non-positive maxSteps to at least one turn', function (int $maxSteps) { +test('it clamps non-positive maxSteps to at least one turn', function (int $maxSteps): void { $gateway = new TextGenerationLoopFakeGateway([ new StepResponse( text: 'hi', @@ -137,7 +135,6 @@ [], null, new TextGenerationOptions(maxSteps: $maxSteps), - null, ); expect($gateway->generateCalls)->toBe(1) @@ -147,7 +144,7 @@ 'negative' => -3, ]); -test('it accumulates streamed usage across multi-step turns', function () { +test('it accumulates streamed usage across multi-step turns', function (): void { $tool = new TextGenerationLoopCountingTool; $toolCall = new ToolCall('call-1', TextGenerationLoopCountingTool::class, [], 'call-1'); $gateway = new TextGenerationLoopFakeGateway(streams: [ @@ -170,7 +167,6 @@ [$tool], null, new TextGenerationOptions(maxSteps: 2), - null, )); $streamEnd = collect($events)->whereInstanceOf(StreamEnd::class)->first(); @@ -181,7 +177,7 @@ ->and($streamEnd->reason)->toBe(FinishReason::Stop->value); }); -test('it throws when generation tool calls do not match local tools', function () { +test('it throws when generation tool calls do not match local tools', function (): void { $gateway = new TextGenerationLoopFakeGateway([ new StepResponse( text: '', @@ -193,19 +189,16 @@ ), ]); - expect(fn () => (new TextGenerationLoop($gateway))->generate( + expect(fn (): TextResponse => (new TextGenerationLoop($gateway))->generate( textGenerationLoopProvider(), 'model', null, [], [], - null, - null, - null, ))->toThrow(NoSuchToolException::class, "Model tried to call unavailable tool 'MissingTool'."); }); -test('it throws when streaming tool calls do not match local tools', function () { +test('it throws when streaming tool calls do not match local tools', function (): void { $toolCall = new ToolCall('call-1', 'MissingTool', [], 'call-1'); $gateway = new TextGenerationLoopFakeGateway(streams: [ textGenerationLoopStreamStep( @@ -214,20 +207,17 @@ ), ]); - expect(fn () => iterator_to_array((new TextGenerationLoop($gateway))->stream( + expect(fn (): array => iterator_to_array((new TextGenerationLoop($gateway))->stream( 'invocation-1', textGenerationLoopProvider(), 'model', null, [], [], - null, - null, - null, )))->toThrow(NoSuchToolException::class); }); -test('it emits a terminal stream end when a turn yields no stream end or error', function () { +test('it emits a terminal stream end when a turn yields no stream end or error', function (): void { $gateway = new TextGenerationLoopFakeGateway(streams: [ textGenerationLoopStreamStep(events: [new TextDelta('text-delta', 'message-1', 'partial', time())]), ]); @@ -239,9 +229,6 @@ null, [], [], - null, - null, - null, )); $streamEnds = collect($events)->whereInstanceOf(StreamEnd::class); @@ -250,7 +237,7 @@ ->and($streamEnds->first()->reason)->toBe(FinishReason::Error->value); }); -test('it does not emit a stream end when a turn errors without a stream end', function () { +test('it does not emit a stream end when a turn errors without a stream end', function (): void { $gateway = new TextGenerationLoopFakeGateway(streams: [ textGenerationLoopStreamStep(events: [new Error('error-1', 'server_error', 'Server overloaded', false, time())]), ]); @@ -262,9 +249,6 @@ null, [], [], - null, - null, - null, )); expect(collect($events)->whereInstanceOf(StreamEnd::class))->toHaveCount(0) diff --git a/tests/Unit/Gateway/TextGenerationOptionsTest.php b/tests/Unit/Gateway/TextGenerationOptionsTest.php index a73958e16..924dc88ae 100644 --- a/tests/Unit/Gateway/TextGenerationOptionsTest.php +++ b/tests/Unit/Gateway/TextGenerationOptionsTest.php @@ -35,21 +35,21 @@ public function instructions(): string } } -it('normalizes a string driver name to a Lab enum so the documented match idiom works', function () { +it('normalizes a string driver name to a Lab enum so the documented match idiom works', function (): void { $options = TextGenerationOptions::forAgent(new ProviderOptionsAgent); expect($options->providerOptions('openrouter')) ->toBe(['reasoning' => ['effort' => 'medium']]); }); -it('accepts a Lab enum directly without re-normalizing', function () { +it('accepts a Lab enum directly without re-normalizing', function (): void { $options = TextGenerationOptions::forAgent(new ProviderOptionsAgent); expect($options->providerOptions(Lab::Anthropic)) ->toBe(['thinking' => ['type' => 'enabled']]); }); -it('passes the raw string through when it does not match any Lab case', function () { +it('passes the raw string through when it does not match any Lab case', function (): void { $agent = new class implements Agent, HasProviderOptions { use Promptable; @@ -71,7 +71,7 @@ public function providerOptions(Lab|string $provider): array ->toBe(['received' => 'unknown-driver']); }); -it('returns null when the agent does not implement HasProviderOptions', function () { +it('returns null when the agent does not implement HasProviderOptions', function (): void { $options = TextGenerationOptions::forAgent(new NoProviderOptionsAgent); expect($options->providerOptions('openai'))->toBeNull(); diff --git a/tests/Unit/Gateway/Xai/ToolMappingTest.php b/tests/Unit/Gateway/Xai/ToolMappingTest.php index 0dccbf769..5898b5640 100644 --- a/tests/Unit/Gateway/Xai/ToolMappingTest.php +++ b/tests/Unit/Gateway/Xai/ToolMappingTest.php @@ -6,7 +6,7 @@ use Laravel\Ai\Providers\Provider; use Laravel\Ai\Tools\Request; -test('tool parameters are not wrapped in schema definition', function () { +test('tool parameters are not wrapped in schema definition', function (): void { $mapper = new class { use MapsTools; @@ -54,7 +54,7 @@ public function schema(JsonSchema $schema): array ->and($parameters['additionalProperties'])->toBeFalse(); }); -test('tool with empty schema includes parameters', function () { +test('tool with empty schema includes parameters', function (): void { $mapper = new class { use MapsTools; diff --git a/tests/Unit/ObjectSchemaTest.php b/tests/Unit/ObjectSchemaTest.php index 6df40d24d..82b6dba9a 100644 --- a/tests/Unit/ObjectSchemaTest.php +++ b/tests/Unit/ObjectSchemaTest.php @@ -3,7 +3,7 @@ use Illuminate\JsonSchema\JsonSchemaTypeFactory; use Laravel\Ai\ObjectSchema; -test('nested objects include additional properties false', function () { +test('nested objects include additional properties false', function (): void { $schema = new JsonSchemaTypeFactory; $objectSchema = new ObjectSchema([ @@ -20,7 +20,7 @@ ->and($result['properties']['address']['additionalProperties'])->toBeFalse(); }); -test('objects nested in arrays include additional properties false', function () { +test('objects nested in arrays include additional properties false', function (): void { $schema = new JsonSchemaTypeFactory; $objectSchema = new ObjectSchema([ @@ -38,7 +38,7 @@ ->and($result['properties']['items']['items']['additionalProperties'])->toBeFalse(); }); -test('deeply nested objects include additional properties false', function () { +test('deeply nested objects include additional properties false', function (): void { $schema = new JsonSchemaTypeFactory; $objectSchema = new ObjectSchema([ @@ -58,7 +58,7 @@ ->and($result['properties']['user']['properties']['contact']['additionalProperties'])->toBeFalse(); }); -test('objects in nested arrays include additional properties false', function () { +test('objects in nested arrays include additional properties false', function (): void { $schema = new JsonSchemaTypeFactory; $objectSchema = new ObjectSchema([ @@ -76,7 +76,7 @@ expect($result['properties']['matrix']['items']['items']['additionalProperties'])->toBeFalse(); }); -test('nullable nested objects include additional properties false', function () { +test('nullable nested objects include additional properties false', function (): void { $schema = new JsonSchemaTypeFactory; $objectSchema = new ObjectSchema([ diff --git a/tests/Unit/Providers/BedrockProviderTest.php b/tests/Unit/Providers/BedrockProviderTest.php index ec15fe122..e199f8d99 100644 --- a/tests/Unit/Providers/BedrockProviderTest.php +++ b/tests/Unit/Providers/BedrockProviderTest.php @@ -5,15 +5,15 @@ use Laravel\Ai\Gateway\Bedrock\BedrockTextGateway; use Laravel\Ai\Providers\BedrockProvider; -beforeEach(function () { +beforeEach(function (): void { $this->dispatcher = Mockery::mock(Dispatcher::class); }); -afterEach(function () { +afterEach(function (): void { Mockery::close(); }); -test('can be instantiated with config', function () { +test('can be instantiated with config', function (): void { $config = [ 'driver' => 'bedrock', 'name' => 'bedrock', @@ -27,7 +27,7 @@ expect($provider)->toBeInstanceOf(BedrockProvider::class); }); -test('returns iam credentials', function () { +test('returns iam credentials', function (): void { $config = [ 'access_key_id' => 'test-key', 'secret_access_key' => 'test-secret', @@ -47,7 +47,7 @@ ->and($credentials['session_token'])->toBe('test-session'); }); -test('filters out empty credential values', function () { +test('filters out empty credential values', function (): void { $config = [ 'access_key_id' => 'test-key', 'secret_access_key' => 'test-secret', @@ -63,7 +63,7 @@ ->not->toHaveKey('session_token'); }); -test('returns additional configuration with region', function () { +test('returns additional configuration with region', function (): void { $config = [ 'region' => 'us-west-2', 'use_default_credential_provider' => true, @@ -80,7 +80,7 @@ ->and($additionalConfig['use_default_credential_provider'])->toBeTrue(); }); -test('preserves false value for use default credential provider', function () { +test('preserves false value for use default credential provider', function (): void { $config = [ 'region' => 'us-east-1', 'use_default_credential_provider' => false, @@ -95,7 +95,7 @@ expect($additionalConfig['use_default_credential_provider'])->toBeFalse(); }); -test('returns bearer token credential when provided', function () { +test('returns bearer token credential when provided', function (): void { $config = [ 'key' => 'bedrock-bearer-token', ]; @@ -107,7 +107,7 @@ ->and($credentials['key'])->toBe('bedrock-bearer-token'); }); -test('defaults to us east 1 region when not specified', function () { +test('defaults to us east 1 region when not specified', function (): void { $provider = new BedrockProvider([], $this->dispatcher); $additionalConfig = $provider->additionalConfiguration(); @@ -115,25 +115,25 @@ ->and($additionalConfig['region'])->toBe('us-east-1'); }); -test('returns default text model', function () { +test('returns default text model', function (): void { $provider = new BedrockProvider([], $this->dispatcher); expect($provider->defaultTextModel())->toBe('us.anthropic.claude-sonnet-4-5-20250929-v1:0'); }); -test('returns cheapest text model', function () { +test('returns cheapest text model', function (): void { $provider = new BedrockProvider([], $this->dispatcher); expect($provider->cheapestTextModel())->toBe('us.anthropic.claude-haiku-4-5-20251001-v1:0'); }); -test('returns smartest text model', function () { +test('returns smartest text model', function (): void { $provider = new BedrockProvider([], $this->dispatcher); expect($provider->smartestTextModel())->toBe('us.anthropic.claude-opus-4-6-v1'); }); -test('allows custom text models in config', function () { +test('allows custom text models in config', function (): void { $config = [ 'models' => [ 'text' => [ @@ -151,19 +151,19 @@ ->and($provider->smartestTextModel())->toBe('custom-smartest'); }); -test('returns default embeddings model', function () { +test('returns default embeddings model', function (): void { $provider = new BedrockProvider([], $this->dispatcher); expect($provider->defaultEmbeddingsModel())->toBe('amazon.titan-embed-text-v2:0'); }); -test('returns default embeddings dimensions', function () { +test('returns default embeddings dimensions', function (): void { $provider = new BedrockProvider([], $this->dispatcher); expect($provider->defaultEmbeddingsDimensions())->toBe(1024); }); -test('allows custom embeddings config', function () { +test('allows custom embeddings config', function (): void { $config = [ 'models' => [ 'embeddings' => [ @@ -179,13 +179,13 @@ ->and($provider->defaultEmbeddingsDimensions())->toBe(1536); }); -test('returns default image model', function () { +test('returns default image model', function (): void { $provider = new BedrockProvider([], $this->dispatcher); expect($provider->defaultImageModel())->toBe('amazon.nova-canvas-v1:0'); }); -test('returns default image options', function () { +test('returns default image options', function (): void { $provider = new BedrockProvider([], $this->dispatcher); $options = $provider->defaultImageOptions(); @@ -197,7 +197,7 @@ ->and($options['size'])->toBe('1024x1024'); }); -test('converts size ratios to dimensions for images', function () { +test('converts size ratios to dimensions for images', function (): void { $provider = new BedrockProvider([], $this->dispatcher); expect($provider->defaultImageOptions('1:1')['size'])->toBe('1024x1024') @@ -205,7 +205,7 @@ ->and($provider->defaultImageOptions('3:2')['size'])->toBe('1152x768'); }); -test('normalizes canonical quality values to bedrock values', function () { +test('normalizes canonical quality values to bedrock values', function (): void { $provider = new BedrockProvider([], $this->dispatcher); expect($provider->defaultImageOptions(quality: 'low')['quality'])->toBe('standard') @@ -215,25 +215,25 @@ ->and($provider->defaultImageOptions(quality: 'premium')['quality'])->toBe('premium'); }); -test('creates text gateway', function () { +test('creates text gateway', function (): void { $provider = new BedrockProvider([], $this->dispatcher); expect($provider->textGateway())->toBeInstanceOf(BedrockTextGateway::class); }); -test('creates embedding gateway', function () { +test('creates embedding gateway', function (): void { $provider = new BedrockProvider([], $this->dispatcher); expect($provider->embeddingGateway())->toBeInstanceOf(BedrockTextGateway::class); }); -test('creates image gateway', function () { +test('creates image gateway', function (): void { $provider = new BedrockProvider([], $this->dispatcher); expect($provider->imageGateway())->toBeInstanceOf(BedrockImageGateway::class); }); -test('reuses gateway instances', function () { +test('reuses gateway instances', function (): void { $provider = new BedrockProvider([], $this->dispatcher); $gateway1 = $provider->textGateway(); diff --git a/tests/Unit/Responses/Data/FinishReasonTest.php b/tests/Unit/Responses/Data/FinishReasonTest.php index f68ef345b..6acb403d9 100644 --- a/tests/Unit/Responses/Data/FinishReasonTest.php +++ b/tests/Unit/Responses/Data/FinishReasonTest.php @@ -2,7 +2,7 @@ use Laravel\Ai\Responses\Data\FinishReason; -test('finish reason enum has expected cases', function () { +test('finish reason enum has expected cases', function (): void { expect(FinishReason::Stop->value)->toBe('stop') ->and(FinishReason::ToolCalls->value)->toBe('tool_calls') ->and(FinishReason::Length->value)->toBe('length') diff --git a/tests/Unit/Responses/Data/GeneratedImageTest.php b/tests/Unit/Responses/Data/GeneratedImageTest.php index 5a9b4fb43..a8dac96c8 100644 --- a/tests/Unit/Responses/Data/GeneratedImageTest.php +++ b/tests/Unit/Responses/Data/GeneratedImageTest.php @@ -2,27 +2,27 @@ use Laravel\Ai\Responses\Data\GeneratedImage; -test('generated image stores base64 encoded image', function () { +test('generated image stores base64 encoded image', function (): void { $image = new GeneratedImage('SGVsbG8gV29ybGQ=', 'image/png'); expect($image->image)->toBe('SGVsbG8gV29ybGQ=') ->and($image->mime)->toBe('image/png'); }); -test('generated image accepts null mime type', function () { +test('generated image accepts null mime type', function (): void { $image = new GeneratedImage('YmFzZTY0IGRhdGE='); expect($image->image)->toBe('YmFzZTY0IGRhdGE=') ->and($image->mime)->toBeNull(); }); -test('generated image content decodes base64 to string', function () { +test('generated image content decodes base64 to string', function (): void { $image = new GeneratedImage('SGVsbG8gV29ybGQ='); expect($image->content())->toBe('Hello World'); }); -test('generated image to array includes image and mime', function () { +test('generated image to array includes image and mime', function (): void { $image = new GeneratedImage('dGVzdCBpbWFnZQ==', 'image/jpeg'); $array = $image->toArray(); @@ -31,7 +31,7 @@ ->and($array['mime'])->toBe('image/jpeg'); }); -test('generated image json serialize returns to array', function () { +test('generated image json serialize returns to array', function (): void { $image = new GeneratedImage('ZGF0YQ==', 'image/webp'); $json = $image->jsonSerialize(); @@ -40,7 +40,7 @@ ->and($json['mime'])->toBe('image/webp'); }); -test('generated image to string returns decoded content', function () { +test('generated image to string returns decoded content', function (): void { $image = new GeneratedImage('VGVzdA=='); expect((string) $image)->toBe('Test'); diff --git a/tests/Unit/Responses/Data/MetaTest.php b/tests/Unit/Responses/Data/MetaTest.php index d8e6be206..97c6f4eb0 100644 --- a/tests/Unit/Responses/Data/MetaTest.php +++ b/tests/Unit/Responses/Data/MetaTest.php @@ -2,27 +2,27 @@ use Laravel\Ai\Responses\Data\Meta; -test('meta extracts provider and model from response', function () { +test('meta extracts provider and model from response', function (): void { $meta = new Meta('openai', 'gpt-4o'); expect($meta->provider)->toBe('openai') ->and($meta->model)->toBe('gpt-4o'); }); -test('meta accepts null provider and model', function () { - $meta = new Meta(null, null); +test('meta accepts null provider and model', function (): void { + $meta = new Meta; expect($meta->provider)->toBeNull() ->and($meta->model)->toBeNull(); }); -test('meta returns empty citations when not provided', function () { +test('meta returns empty citations when not provided', function (): void { $meta = new Meta('openai', 'gpt-4o'); expect($meta->citations)->toBeEmpty(); }); -test('meta to array includes provider model and citations', function () { +test('meta to array includes provider model and citations', function (): void { $meta = new Meta('openai', 'gpt-4o'); $array = $meta->toArray(); @@ -32,7 +32,7 @@ ->and($array['citations'])->toBe([]); }); -test('meta json serialize returns to array', function () { +test('meta json serialize returns to array', function (): void { $meta = new Meta('anthropic', 'claude-3-opus'); $json = $meta->jsonSerialize(); diff --git a/tests/Unit/Responses/Data/RankedDocumentTest.php b/tests/Unit/Responses/Data/RankedDocumentTest.php index 1fe450c7d..fccd8e06e 100644 --- a/tests/Unit/Responses/Data/RankedDocumentTest.php +++ b/tests/Unit/Responses/Data/RankedDocumentTest.php @@ -2,7 +2,7 @@ use Laravel\Ai\Responses\Data\RankedDocument; -test('ranked document stores index document and score', function () { +test('ranked document stores index document and score', function (): void { $doc = new RankedDocument(0, 'test document content', 0.95); expect($doc->index)->toBe(0) @@ -10,7 +10,7 @@ ->and($doc->score)->toBe(0.95); }); -test('ranked document to array returns all properties', function () { +test('ranked document to array returns all properties', function (): void { $doc = new RankedDocument(2, 'relevant content', 0.85); $array = $doc->toArray(); @@ -20,7 +20,7 @@ ->and($array['score'])->toBe(0.85); }); -test('ranked document json serialize returns to array', function () { +test('ranked document json serialize returns to array', function (): void { $doc = new RankedDocument(1, 'search result', 0.75); $json = $doc->jsonSerialize(); @@ -30,7 +30,7 @@ ->and($json['score'])->toBe(0.75); }); -test('ranked document to string returns document content', function () { +test('ranked document to string returns document content', function (): void { $doc = new RankedDocument(0, 'document text content', 0.5); expect((string) $doc)->toBe('document text content'); diff --git a/tests/Unit/Responses/Data/StepTest.php b/tests/Unit/Responses/Data/StepTest.php index 6c59616d6..b00ae98df 100644 --- a/tests/Unit/Responses/Data/StepTest.php +++ b/tests/Unit/Responses/Data/StepTest.php @@ -5,7 +5,7 @@ use Laravel\Ai\Responses\Data\Step; use Laravel\Ai\Responses\Data\Usage; -test('step stores text tool calls and other properties', function () { +test('step stores text tool calls and other properties', function (): void { $usage = new Usage(10, 5); $meta = new Meta('openai', 'gpt-4o'); $step = new Step( @@ -25,7 +25,7 @@ ->and($step->meta)->toBe($meta); }); -test('step to array returns all properties including serialized usage and meta', function () { +test('step to array returns all properties including serialized usage and meta', function (): void { $usage = new Usage(10, 5); $meta = new Meta('openai', 'gpt-4o'); $step = new Step('test', [], [], FinishReason::Stop, $usage, $meta); @@ -40,9 +40,9 @@ ->and($array['meta'])->toBe($meta); }); -test('step json serialize returns to array', function () { +test('step json serialize returns to array', function (): void { $usage = new Usage(0, 0); - $meta = new Meta(null, null); + $meta = new Meta; $step = new Step('', [], [], FinishReason::Unknown, $usage, $meta); expect($step->jsonSerialize())->toBe($step->toArray()); diff --git a/tests/Unit/Responses/Data/StoreFileCountsTest.php b/tests/Unit/Responses/Data/StoreFileCountsTest.php index 81db8cd1d..27a42e416 100644 --- a/tests/Unit/Responses/Data/StoreFileCountsTest.php +++ b/tests/Unit/Responses/Data/StoreFileCountsTest.php @@ -2,7 +2,7 @@ use Laravel\Ai\Responses\Data\StoreFileCounts; -test('store file counts stores completed pending and failed counts', function () { +test('store file counts stores completed pending and failed counts', function (): void { $counts = new StoreFileCounts(10, 5, 2); expect($counts->completed)->toBe(10) @@ -10,7 +10,7 @@ ->and($counts->failed)->toBe(2); }); -test('store file counts to array returns all counts', function () { +test('store file counts to array returns all counts', function (): void { $counts = new StoreFileCounts(1, 0, 0); $array = $counts->toArray(); @@ -22,7 +22,7 @@ ]); }); -test('store file counts json serialize returns to array', function () { +test('store file counts json serialize returns to array', function (): void { $counts = new StoreFileCounts(5, 5, 5); expect($counts->jsonSerialize())->toBe($counts->toArray()); diff --git a/tests/Unit/Responses/Data/StructuredStepTest.php b/tests/Unit/Responses/Data/StructuredStepTest.php index c09c33fb3..fa070156d 100644 --- a/tests/Unit/Responses/Data/StructuredStepTest.php +++ b/tests/Unit/Responses/Data/StructuredStepTest.php @@ -5,7 +5,7 @@ use Laravel\Ai\Responses\Data\StructuredStep; use Laravel\Ai\Responses\Data\Usage; -test('structured step stores structured data', function () { +test('structured step stores structured data', function (): void { $step = new StructuredStep( 'test response', ['key' => 'value'], @@ -20,7 +20,7 @@ ->and($step->structured)->toBe(['key' => 'value']); }); -test('structured step to array includes structured data', function () { +test('structured step to array includes structured data', function (): void { $step = new StructuredStep( 'text content', ['name' => 'test', 'count' => 5], @@ -37,7 +37,7 @@ ->and($array['structured'])->toBe(['name' => 'test', 'count' => 5]); }); -test('structured step jsonSerialize includes structured data', function () { +test('structured step jsonSerialize includes structured data', function (): void { $step = new StructuredStep( 'response text', ['result' => true], diff --git a/tests/Unit/Responses/Data/ToolCallTest.php b/tests/Unit/Responses/Data/ToolCallTest.php index 1f519d1c1..0f65182ec 100644 --- a/tests/Unit/Responses/Data/ToolCallTest.php +++ b/tests/Unit/Responses/Data/ToolCallTest.php @@ -2,7 +2,7 @@ use Laravel\Ai\Responses\Data\ToolCall; -test('tool call stores all properties', function () { +test('tool call stores all properties', function (): void { $toolCall = new ToolCall( id: 'call_123', name: 'get_weather', @@ -22,7 +22,7 @@ ->and($toolCall->reasoningEncryptedContent)->toBe('enc-blob-1'); }); -test('tool call to array returns all properties', function () { +test('tool call to array returns all properties', function (): void { $toolCall = new ToolCall('id', 'name', ['arg' => 'val']); $array = $toolCall->toArray(); @@ -38,7 +38,7 @@ ]); }); -test('tool call json serialize returns to array', function () { +test('tool call json serialize returns to array', function (): void { $toolCall = new ToolCall('id', 'name', []); expect($toolCall->jsonSerialize())->toBe($toolCall->toArray()); diff --git a/tests/Unit/Responses/Data/ToolResultTest.php b/tests/Unit/Responses/Data/ToolResultTest.php index 2fffc5224..7f2175d7c 100644 --- a/tests/Unit/Responses/Data/ToolResultTest.php +++ b/tests/Unit/Responses/Data/ToolResultTest.php @@ -2,7 +2,7 @@ use Laravel\Ai\Responses\Data\ToolResult; -test('tool result stores all properties', function () { +test('tool result stores all properties', function (): void { $result = new ToolResult( id: 'call_123', name: 'get_weather', @@ -18,7 +18,7 @@ ->and($result->resultId)->toBe('msg_789'); }); -test('tool result to array returns all properties', function () { +test('tool result to array returns all properties', function (): void { $result = new ToolResult('id', 'name', [], 'raw result'); $array = $result->toArray(); @@ -32,7 +32,7 @@ ]); }); -test('tool result json serialize returns to array', function () { +test('tool result json serialize returns to array', function (): void { $result = new ToolResult('id', 'name', [], 'val'); expect($result->jsonSerialize())->toBe($result->toArray()); diff --git a/tests/Unit/Responses/Data/TranscriptionSegmentTest.php b/tests/Unit/Responses/Data/TranscriptionSegmentTest.php index 07b9bcaf8..8576080e4 100644 --- a/tests/Unit/Responses/Data/TranscriptionSegmentTest.php +++ b/tests/Unit/Responses/Data/TranscriptionSegmentTest.php @@ -2,7 +2,7 @@ use Laravel\Ai\Responses\Data\TranscriptionSegment; -test('transcription segment stores text speaker and timestamps', function () { +test('transcription segment stores text speaker and timestamps', function (): void { $segment = new TranscriptionSegment('Hello world', 'Speaker 1', 0.0, 2.5); expect($segment->text)->toBe('Hello world') @@ -11,7 +11,7 @@ ->and($segment->endSeconds)->toBe(2.5); }); -test('transcription segment to array returns all properties', function () { +test('transcription segment to array returns all properties', function (): void { $segment = new TranscriptionSegment('Test speech', 'Speaker A', 1.0, 3.5); $array = $segment->toArray(); @@ -22,7 +22,7 @@ ->and($array['end_seconds'])->toBe(3.5); }); -test('transcription segment json serialize returns to array', function () { +test('transcription segment json serialize returns to array', function (): void { $segment = new TranscriptionSegment('Content', 'Speaker', 5.0, 10.0); $json = $segment->jsonSerialize(); diff --git a/tests/Unit/Responses/Data/UrlCitationTest.php b/tests/Unit/Responses/Data/UrlCitationTest.php index 896018747..0cfbe29b0 100644 --- a/tests/Unit/Responses/Data/UrlCitationTest.php +++ b/tests/Unit/Responses/Data/UrlCitationTest.php @@ -2,7 +2,7 @@ use Laravel\Ai\Responses\Data\UrlCitation; -test('url citation stores url and title', function () { +test('url citation stores url and title', function (): void { $citation = new UrlCitation('https://example.com', 'Example'); expect($citation->url)->toBe('https://example.com') @@ -11,14 +11,14 @@ ->and($citation->endIndex)->toBeNull(); }); -test('url citation stores span indices when provided', function () { +test('url citation stores span indices when provided', function (): void { $citation = new UrlCitation('https://example.com', 'Example', startIndex: 12, endIndex: 45); expect($citation->startIndex)->toBe(12) ->and($citation->endIndex)->toBe(45); }); -test('url citation to array returns all fields', function () { +test('url citation to array returns all fields', function (): void { $citation = new UrlCitation('https://laravel.com', 'Laravel', startIndex: 0, endIndex: 7); expect($citation->toArray())->toBe([ @@ -29,7 +29,7 @@ ]); }); -test('url citation json serialize returns to array', function () { +test('url citation json serialize returns to array', function (): void { $citation = new UrlCitation('https://google.com'); expect($citation->jsonSerialize())->toBe([ diff --git a/tests/Unit/Responses/Data/UsageTest.php b/tests/Unit/Responses/Data/UsageTest.php index ca37c413c..0f42a9e1e 100644 --- a/tests/Unit/Responses/Data/UsageTest.php +++ b/tests/Unit/Responses/Data/UsageTest.php @@ -2,7 +2,7 @@ use Laravel\Ai\Responses\Data\Usage; -test('usage defaults all tokens to zero', function () { +test('usage defaults all tokens to zero', function (): void { $usage = new Usage; expect($usage->promptTokens)->toBe(0) @@ -12,7 +12,7 @@ ->and($usage->reasoningTokens)->toBe(0); }); -test('usage accepts token values in constructor', function () { +test('usage accepts token values in constructor', function (): void { $usage = new Usage(100, 50, 25, 10, 5); expect($usage->promptTokens)->toBe(100) @@ -22,7 +22,7 @@ ->and($usage->reasoningTokens)->toBe(5); }); -test('usage add returns new instance with summed tokens', function () { +test('usage add returns new instance with summed tokens', function (): void { $usage1 = new Usage(100, 50, 25, 10, 5); $usage2 = new Usage(50, 25, 10, 5, 0); @@ -35,7 +35,7 @@ ->and($combined->reasoningTokens)->toBe(5); }); -test('usage add does not mutate original', function () { +test('usage add does not mutate original', function (): void { $usage1 = new Usage(100, 50, 25, 10, 5); $usage2 = new Usage(50, 25, 10, 5, 0); @@ -45,7 +45,7 @@ ->and($usage1->completionTokens)->toBe(50); }); -test('usage to array includes all token fields', function () { +test('usage to array includes all token fields', function (): void { $usage = new Usage(100, 50, 25, 10, 5); $array = $usage->toArray(); @@ -57,7 +57,7 @@ ->and($array['reasoning_tokens'])->toBe(5); }); -test('usage json serialize returns to array', function () { +test('usage json serialize returns to array', function (): void { $usage = new Usage(200, 100, 50, 20, 10); $json = $usage->jsonSerialize(); diff --git a/tests/Unit/Responses/ImageResponseTest.php b/tests/Unit/Responses/ImageResponseTest.php index d9458e850..ede632901 100644 --- a/tests/Unit/Responses/ImageResponseTest.php +++ b/tests/Unit/Responses/ImageResponseTest.php @@ -6,12 +6,12 @@ use Laravel\Ai\Responses\Data\Usage; use Laravel\Ai\Responses\ImageResponse; -test('mime falls back to image/png when no mime type is set', function () { +test('mime falls back to image/png when no mime type is set', function (): void { expect((new GeneratedImage('aGVsbG8='))->mime())->toBe('image/png'); expect((new GeneratedImage('aGVsbG8=', 'image/webp'))->mime())->toBe('image/webp'); }); -test('toHtml renders an img tag with the image mime type', function () { +test('toHtml renders an img tag with the image mime type', function (): void { $response = new ImageResponse( new Collection([new GeneratedImage('aGVsbG8=', 'image/jpeg')]), new Usage, @@ -23,7 +23,7 @@ ); }); -test('toHtml falls back to image/png when the image has no mime type', function () { +test('toHtml falls back to image/png when the image has no mime type', function (): void { $response = new ImageResponse( new Collection([new GeneratedImage('aGVsbG8=')]), new Usage, @@ -35,7 +35,7 @@ ); }); -test('toHtml escapes the alt attribute', function () { +test('toHtml escapes the alt attribute', function (): void { $response = new ImageResponse( new Collection([new GeneratedImage('aGVsbG8=', 'image/png')]), new Usage, @@ -47,13 +47,13 @@ ); }); -test('firstImage throws when the response contains no images', function () { +test('firstImage throws when the response contains no images', function (): void { $response = new ImageResponse( new Collection, new Usage, new Meta('openai', 'gpt-image-1'), ); - expect(fn () => $response->firstImage()) + expect(fn (): GeneratedImage => $response->firstImage()) ->toThrow(RuntimeException::class, 'The image response does not contain any images.'); }); diff --git a/tests/Unit/Schema/SchemaNormalizerTest.php b/tests/Unit/Schema/SchemaNormalizerTest.php index c0dcc4299..be8bd3fdf 100644 --- a/tests/Unit/Schema/SchemaNormalizerTest.php +++ b/tests/Unit/Schema/SchemaNormalizerTest.php @@ -2,12 +2,14 @@ use Illuminate\JsonSchema\JsonSchema as JsonSchemaFactory; use Illuminate\JsonSchema\Serializer; +use Illuminate\JsonSchema\Types\AnyOfType; use Illuminate\JsonSchema\Types\ObjectType; +use Illuminate\JsonSchema\Types\Type; use Laravel\Ai\Schema\SchemaNormalizer; function supportsNativeAnyOf(): bool { - return class_exists('Illuminate\\JsonSchema\\Types\\AnyOfType'); + return class_exists(AnyOfType::class); } /** @@ -17,12 +19,12 @@ function normalizesWithoutThrowing(array $raw): array { $normalized = SchemaNormalizer::normalize($raw); - expect(fn () => JsonSchemaFactory::fromArray($normalized))->not->toThrow(Throwable::class); + expect(fn (): Type => JsonSchemaFactory::fromArray($normalized))->not->toThrow(Throwable::class); return $normalized; } -test('it passes a plain object schema through unchanged', function () { +test('it passes a plain object schema through unchanged', function (): void { $raw = [ 'type' => 'object', 'properties' => ['name' => ['type' => 'string', 'description' => 'A name.']], @@ -32,7 +34,7 @@ function normalizesWithoutThrowing(array $raw): array expect(SchemaNormalizer::normalize($raw))->toBe($raw); }); -test('it preserves a nullable anyOf when the framework supports it natively', function () { +test('it preserves a nullable anyOf when the framework supports it natively', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -48,7 +50,7 @@ function normalizesWithoutThrowing(array $raw): array ]); })->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); -test('it collapses a nullable anyOf to a nullable single type when native support is unavailable', function () { +test('it collapses a nullable anyOf to a nullable single type when native support is unavailable', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -62,7 +64,7 @@ function normalizesWithoutThrowing(array $raw): array ]); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback collapse.'); -test('it collapses a non-nullable scalar oneOf to a multi-type union', function () { +test('it collapses a non-nullable scalar oneOf to a multi-type union', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -74,7 +76,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['choice'])->not->toHaveKey('maxLength'); }); -test('it strips type-specific keywords from a multi-type union so it deserializes', function () { +test('it strips type-specific keywords from a multi-type union so it deserializes', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -87,7 +89,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['value'])->not->toHaveKey('minimum'); }); -test('it merges allOf branches into the node', function () { +test('it merges allOf branches into the node', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -102,7 +104,7 @@ function normalizesWithoutThrowing(array $raw): array ]); }); -test('it drops keywords the deserializer cannot represent', function () { +test('it drops keywords the deserializer cannot represent', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', '$schema' => 'http://json-schema.org/draft-07/schema#', @@ -121,7 +123,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['a'])->toBe(['type' => 'string']); }); -test('it drops boolean property schemas', function () { +test('it drops boolean property schemas', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -136,7 +138,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['required'])->toBe(['name']); }); -test('it drops tuple and boolean array items', function () { +test('it drops tuple and boolean array items', function (): void { $tuple = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => ['list' => ['type' => 'array', 'items' => [['type' => 'string'], ['type' => 'integer']]]], @@ -152,7 +154,7 @@ function normalizesWithoutThrowing(array $raw): array expect($single['properties']['list']['items'])->toBe(['type' => 'string']); }); -test('it inlines local $ref and drops remote or unresolvable $ref', function () { +test('it inlines local $ref and drops remote or unresolvable $ref', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -175,7 +177,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized)->not->toHaveKey('$defs'); }); -test('it breaks circular $ref without infinite recursion', function () { +test('it breaks circular $ref without infinite recursion', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => ['node' => ['$ref' => '#/$defs/Node']], @@ -191,7 +193,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['node']['properties']['child'])->toBe(['type' => 'string']); }); -test('it drops a null default', function () { +test('it drops a null default', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => ['flag' => ['type' => 'boolean', 'default' => null]], @@ -200,7 +202,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['flag'])->not->toHaveKey('default'); }); -test('it keeps additionalProperties false but drops permissive forms', function () { +test('it keeps additionalProperties false but drops permissive forms', function (): void { $normalized = SchemaNormalizer::normalize([ 'type' => 'object', 'additionalProperties' => false, @@ -213,7 +215,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['open'])->not->toHaveKey('additionalProperties'); }); -test('it assigns a type to an otherwise-typeless allOf root so it does not throw', function () { +test('it assigns a type to an otherwise-typeless allOf root so it does not throw', function (): void { $normalized = normalizesWithoutThrowing([ 'allOf' => [['description' => 'x'], ['title' => 'y']], ]); @@ -222,7 +224,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized)->not->toHaveKey('allOf'); }); -test('it unions required across allOf branches and the outer node', function () { +test('it unions required across allOf branches and the outer node', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'required' => ['c'], @@ -237,7 +239,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties'])->toHaveKeys(['a', 'b', 'c']); }); -test('it strips unsupported keywords pulled in from an allOf branch', function () { +test('it strips unsupported keywords pulled in from an allOf branch', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -248,7 +250,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['n'])->toBe(['type' => 'integer']); }); -test('it preserves object shape inside nullable anyOf when the framework supports it natively', function () { +test('it preserves object shape inside nullable anyOf when the framework supports it natively', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -263,7 +265,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['addr']['anyOf'][0]['properties'])->toHaveKey('city'); })->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); -test('it preserves object shape for a nullable object branch when native support is unavailable', function () { +test('it preserves object shape for a nullable object branch when native support is unavailable', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -278,7 +280,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['addr']['properties'])->toHaveKey('city'); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback collapse.'); -test('it preserves anyOf compositions when the framework deserializer supports them', function () { +test('it preserves anyOf compositions when the framework deserializer supports them', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -304,7 +306,7 @@ function normalizesWithoutThrowing(array $raw): array ]); })->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); -test('it folds outer object siblings into each branch so the deserializer keeps them', function () { +test('it folds outer object siblings into each branch so the deserializer keeps them', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => ['kind' => ['type' => 'string']], @@ -324,7 +326,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['anyOf'][1]['required'])->toEqualCanonicalizing(['kind', 'age']); })->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); -test('it keeps the shared base property after round-tripping a sibling anyOf through the deserializer', function () { +test('it keeps the shared base property after round-tripping a sibling anyOf through the deserializer', function (): void { $type = JsonSchemaFactory::fromArray(SchemaNormalizer::normalize([ 'type' => 'object', 'properties' => ['kind' => ['type' => 'string']], @@ -338,7 +340,7 @@ function normalizesWithoutThrowing(array $raw): array expect(json_encode((new Serializer)->serialize($type)))->toContain('"kind"'); })->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); -test('it keeps the outer description on a preserved anyOf composition', function () { +test('it keeps the outer description on a preserved anyOf composition', function (): void { $normalized = normalizesWithoutThrowing([ 'description' => 'A union.', 'anyOf' => [['type' => 'string'], ['type' => 'integer']], @@ -348,7 +350,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['anyOf'])->toHaveCount(2); })->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); -test('it drops empty branches from a preserved anyOf instead of inventing a string branch', function () { +test('it drops empty branches from a preserved anyOf instead of inventing a string branch', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -362,7 +364,7 @@ function normalizesWithoutThrowing(array $raw): array ]); })->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); -test('it drops a cyclic anyOf branch instead of inventing a string branch', function () { +test('it drops a cyclic anyOf branch instead of inventing a string branch', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => ['node' => ['$ref' => '#/$defs/Node']], @@ -374,7 +376,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['node']['anyOf'])->toBe([['type' => 'integer']]); })->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); -test('it drops a preserved anyOf made up entirely of null branches', function () { +test('it drops a preserved anyOf made up entirely of null branches', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -386,7 +388,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['v']['type'])->toBe('string'); })->skip(! supportsNativeAnyOf(), 'The installed Illuminate JSON schema package does not support anyOf.'); -test('it types heterogeneous, empty, and homogeneous enums without throwing', function (array $enum, $expected) { +test('it types heterogeneous, empty, and homogeneous enums without throwing', function (array $enum, $expected): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => ['e' => ['enum' => $enum]], @@ -399,7 +401,7 @@ function normalizesWithoutThrowing(array $raw): array 'homogeneous int' => [[1, 2, 3], 'integer'], ]); -test('it replaces a null-only type with a scalar so it deserializes', function (array $raw) { +test('it replaces a null-only type with a scalar so it deserializes', function (array $raw): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => ['n' => $raw], @@ -411,7 +413,7 @@ function normalizesWithoutThrowing(array $raw): array 'null-only array' => [['type' => ['null']]], ]); -test('it re-infers a type when the declared type is not a known JSON Schema type', function () { +test('it re-infers a type when the declared type is not a known JSON Schema type', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -425,7 +427,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['b']['properties'])->toHaveKey('x'); }); -test('it drops unknown members from a multi-type union but keeps the rest', function () { +test('it drops unknown members from a multi-type union but keeps the rest', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -440,7 +442,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['only']['type'])->toBe('string'); }); -test('it produces an ObjectType for a gnarly real-world schema', function () { +test('it produces an ObjectType for a gnarly real-world schema', function (): void { $type = JsonSchemaFactory::fromArray(SchemaNormalizer::normalize([ '$schema' => 'http://json-schema.org/draft-07/schema#', 'type' => 'object', @@ -457,7 +459,7 @@ function normalizesWithoutThrowing(array $raw): array expect($type)->toBeInstanceOf(ObjectType::class); }); -test('it terminates on a recursive allOf instead of hanging', function () { +test('it terminates on a recursive allOf instead of hanging', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => ['node' => ['$ref' => '#/$defs/Node']], @@ -474,7 +476,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['node']['properties'])->toHaveKey('v'); }); -test('it terminates on a nullable recursive union instead of hanging', function () { +test('it terminates on a nullable recursive union instead of hanging', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => ['node' => ['$ref' => '#/$defs/Node']], @@ -491,7 +493,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['node']['properties'])->toHaveKey('child'); }); -test('it resolves arbitrary local JSON pointers, including escaped keys', function () { +test('it resolves arbitrary local JSON pointers, including escaped keys', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -507,7 +509,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['b'])->toMatchArray(['type' => 'integer', 'minimum' => 1]); }); -test('it converts const to a single-value enum', function () { +test('it converts const to a single-value enum', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => ['mode' => ['const' => 'fast']], @@ -516,7 +518,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['mode'])->toBe(['enum' => ['fast'], 'type' => 'string']); }); -test('it infers object type from additionalProperties', function (array $raw) { +test('it infers object type from additionalProperties', function (array $raw): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => ['opts' => $raw], @@ -528,7 +530,7 @@ function normalizesWithoutThrowing(array $raw): array 'false form' => [['additionalProperties' => false]], ]); -test('it merges all object variants from anyOf instead of discarding all but the first', function () { +test('it merges all object variants from anyOf instead of discarding all but the first', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -564,7 +566,7 @@ function normalizesWithoutThrowing(array $raw): array expect($item['required'])->toBe(['type']); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it falls back to the single object branch when fewer than two object variants are present', function () { +test('it falls back to the single object branch when fewer than two object variants are present', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -582,7 +584,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['value']['properties'])->not->toHaveKey('y'); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it merges the object variants and ignores scalar branches when two or more objects are present', function () { +test('it merges the object variants and ignores scalar branches when two or more objects are present', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -600,7 +602,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['value']['properties'])->toHaveKeys(['x', 'y']); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it recursively merges branch refinements into a generic outer property', function () { +test('it recursively merges branch refinements into a generic outer property', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => ['kind' => ['type' => 'string']], @@ -613,7 +615,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['kind']['enum'])->toBe(['a', 'b']); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it recursively merges duplicate nested object properties across object variants', function () { +test('it recursively merges duplicate nested object properties across object variants', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -629,7 +631,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['item']['properties']['payload']['properties'])->toHaveKeys(['a', 'b']); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it ignores a no-opinion variant when intersecting required on a nested object property', function () { +test('it ignores a no-opinion variant when intersecting required on a nested object property', function (): void { $normalized = normalizesWithoutThrowing([ 'anyOf' => [ ['type' => 'object', 'properties' => ['payload' => ['type' => 'object', 'properties' => ['a' => ['type' => 'string'], 'b' => ['type' => 'string']], 'required' => ['a']]]], @@ -641,7 +643,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['payload']['required'])->toBe(['a']); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it keeps a nested object property open unless every variant closes it', function () { +test('it keeps a nested object property open unless every variant closes it', function (): void { $normalized = normalizesWithoutThrowing([ 'anyOf' => [ ['type' => 'object', 'properties' => ['payload' => ['type' => 'object', 'properties' => ['a' => ['type' => 'string']], 'additionalProperties' => false]]], @@ -652,7 +654,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['payload'])->not->toHaveKey('additionalProperties'); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it closes a nested object property only when every variant closes it', function () { +test('it closes a nested object property only when every variant closes it', function (): void { $normalized = normalizesWithoutThrowing([ 'anyOf' => [ ['type' => 'object', 'properties' => ['payload' => ['type' => 'object', 'properties' => ['a' => ['type' => 'string']], 'additionalProperties' => false]]], @@ -663,7 +665,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['payload']['additionalProperties'])->toBeFalse(); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it unions scalar property types when the same key differs across object variants', function () { +test('it unions scalar property types when the same key differs across object variants', function (): void { $normalized = normalizesWithoutThrowing([ 'anyOf' => [ ['type' => 'object', 'properties' => ['id' => ['type' => 'string']]], @@ -674,7 +676,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['id']['type'])->toBe(['string', 'integer']); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it keeps the nested object shape when one variant types a property as an object', function () { +test('it keeps the nested object shape when one variant types a property as an object', function (): void { $normalized = normalizesWithoutThrowing([ 'anyOf' => [ ['type' => 'object', 'properties' => ['id' => ['type' => 'object', 'properties' => ['n' => ['type' => 'string']]]]], @@ -686,7 +688,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['id']['properties'])->toHaveKey('n'); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it does not throw when an object variant has a malformed scalar properties value', function () { +test('it does not throw when an object variant has a malformed scalar properties value', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -702,7 +704,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['item']['properties'])->toHaveKey('b'); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it preserves outer schema properties and required when merging anyOf object variants', function () { +test('it preserves outer schema properties and required when merging anyOf object variants', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => ['kind' => ['type' => 'string']], @@ -717,7 +719,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['required'])->toContain('kind'); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it treats a branch with no required key as no-opinion rather than zero-required', function () { +test('it treats a branch with no required key as no-opinion rather than zero-required', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -741,7 +743,7 @@ function normalizesWithoutThrowing(array $raw): array expect($item['required'])->toBe(['type']); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it merges nullable object variants and marks result nullable', function () { +test('it merges nullable object variants and marks result nullable', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -758,7 +760,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['payload']['properties'])->toHaveKeys(['x', 'y']); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it does not promote required fields when only one of multiple branches declares required', function () { +test('it does not promote required fields when only one of multiple branches declares required', function (): void { $normalized = normalizesWithoutThrowing([ 'anyOf' => [ ['type' => 'object', 'properties' => ['id' => ['type' => 'string'], 'name' => ['type' => 'string']], 'required' => ['id']], @@ -770,7 +772,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties'])->toHaveKeys(['id', 'name', 'code']); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it unions enum values for overlapping discriminator properties across anyOf variants', function () { +test('it unions enum values for overlapping discriminator properties across anyOf variants', function (): void { $normalized = normalizesWithoutThrowing([ 'anyOf' => [ ['type' => 'object', 'properties' => ['kind' => ['type' => 'string', 'enum' => ['open']], 'text' => ['type' => 'string']]], @@ -785,7 +787,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties'])->toHaveKeys(['kind', 'text', 'choices', 'scale']); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it does not bleed branch-specific keys like additionalProperties into the merged result', function () { +test('it does not bleed branch-specific keys like additionalProperties into the merged result', function (): void { $normalized = normalizesWithoutThrowing([ 'type' => 'object', 'properties' => [ @@ -802,7 +804,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['value'])->not->toHaveKey('additionalProperties'); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it prefers the object branch over a scalar branch regardless of branch order', function () { +test('it prefers the object branch over a scalar branch regardless of branch order', function (): void { $stringFirst = normalizesWithoutThrowing([ 'anyOf' => [ ['type' => 'string'], @@ -823,7 +825,7 @@ function normalizesWithoutThrowing(array $raw): array expect($objectFirst)->toBe($stringFirst); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it skips an empty no-opinion branch when intersecting required across object variants', function () { +test('it skips an empty no-opinion branch when intersecting required across object variants', function (): void { $normalized = normalizesWithoutThrowing([ 'anyOf' => [ ['type' => 'object', 'properties' => ['type' => ['type' => 'string', 'enum' => ['behavioral']], 'key' => ['type' => 'string'], 'value' => ['type' => 'string'], 'event_type' => ['type' => 'string']], 'required' => ['type', 'key', 'value', 'event_type']], @@ -838,7 +840,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties']['type']['enum'])->toBe(['behavioral', 'person', 'cohort']); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it skips a leading empty branch instead of degrading the union to a string', function () { +test('it skips a leading empty branch instead of degrading the union to a string', function (): void { $normalized = normalizesWithoutThrowing([ 'anyOf' => [ [], @@ -851,7 +853,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties'])->toHaveKeys(['a', 'b']); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it carries the first object branch description into the merged result', function () { +test('it carries the first object branch description into the merged result', function (): void { $normalized = normalizesWithoutThrowing([ 'anyOf' => [ ['type' => 'object', 'description' => 'Variant A', 'properties' => ['a' => ['type' => 'string']]], @@ -863,7 +865,7 @@ function normalizesWithoutThrowing(array $raw): array expect($normalized['properties'])->toHaveKeys(['a', 'b']); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it keeps additionalProperties false only when every merged object branch agrees', function () { +test('it keeps additionalProperties false only when every merged object branch agrees', function (): void { $unanimous = normalizesWithoutThrowing([ 'anyOf' => [ ['type' => 'object', 'properties' => ['a' => ['type' => 'string']], 'additionalProperties' => false], @@ -882,7 +884,7 @@ function normalizesWithoutThrowing(array $raw): array expect($mixed)->not->toHaveKey('additionalProperties'); })->skip(supportsNativeAnyOf(), 'Native anyOf support preserves compositions instead of using the fallback merge.'); -test('it deep-merges overlapping properties across allOf branches', function () { +test('it deep-merges overlapping properties across allOf branches', function (): void { $normalized = normalizesWithoutThrowing([ 'allOf' => [ ['properties' => ['value' => ['type' => 'string', 'minLength' => 5]]], diff --git a/tests/Unit/ToolChoiceTest.php b/tests/Unit/ToolChoiceTest.php index 0b449291d..49abc30e3 100644 --- a/tests/Unit/ToolChoiceTest.php +++ b/tests/Unit/ToolChoiceTest.php @@ -6,7 +6,7 @@ use Tests\Fixtures\Agents\AttributeToolChoiceAgent; use Tests\Fixtures\Agents\ToolChoiceAgent; -test('constructor and tool() build the expected mode and tool name', function () { +test('constructor and tool() build the expected mode and tool name', function (): void { expect((new ToolChoice(ToolChoice::auto))->mode)->toBe(ToolChoice::auto) ->and((new ToolChoice(ToolChoice::auto))->toolName)->toBeNull() ->and((new ToolChoice(ToolChoice::none))->mode)->toBe(ToolChoice::none) @@ -15,17 +15,17 @@ ->and(ToolChoice::tool('calculator')->toolName)->toBe('calculator'); }); -test('tool mode requires a non-empty tool name', function () { - expect(fn () => new ToolChoice(ToolChoice::tool))->toThrow(InvalidArgumentException::class); - expect(fn () => new ToolChoice(ToolChoice::tool, ''))->toThrow(InvalidArgumentException::class); +test('tool mode requires a non-empty tool name', function (): void { + expect(fn (): ToolChoice => new ToolChoice(ToolChoice::tool))->toThrow(InvalidArgumentException::class); + expect(fn (): ToolChoice => new ToolChoice(ToolChoice::tool, ''))->toThrow(InvalidArgumentException::class); }); -test('non-tool modes reject a tool name', function () { - expect(fn () => new ToolChoice(ToolChoice::auto, 'x'))->toThrow(InvalidArgumentException::class); - expect(fn () => new ToolChoice(ToolChoice::required, 'x'))->toThrow(InvalidArgumentException::class); +test('non-tool modes reject a tool name', function (): void { + expect(fn (): ToolChoice => new ToolChoice(ToolChoice::auto, 'x'))->toThrow(InvalidArgumentException::class); + expect(fn (): ToolChoice => new ToolChoice(ToolChoice::required, 'x'))->toThrow(InvalidArgumentException::class); }); -test('from coerces instances and strings', function () { +test('from coerces instances and strings', function (): void { $choice = new ToolChoice(ToolChoice::required); expect(ToolChoice::from($choice))->toBe($choice) @@ -33,43 +33,43 @@ ->and(ToolChoice::from('required')->mode)->toBe(ToolChoice::required); }); -test('from accepts array shorthand for tool selection', function () { +test('from accepts array shorthand for tool selection', function (): void { expect(ToolChoice::from(['tool' => 'calculator'])->toolName)->toBe('calculator') ->and(ToolChoice::from(['toolName' => 'calculator'])->toolName)->toBe('calculator') ->and(ToolChoice::from(['name' => 'calculator'])->toolName)->toBe('calculator'); }); -test('from rejects invalid values', function () { - expect(fn () => ToolChoice::from('bogus'))->toThrow(InvalidArgumentException::class); - expect(fn () => ToolChoice::from(['unexpected' => 'value']))->toThrow(InvalidArgumentException::class); +test('from rejects invalid values', function (): void { + expect(fn (): ToolChoice => ToolChoice::from('bogus'))->toThrow(InvalidArgumentException::class); + expect(fn (): ToolChoice => ToolChoice::from(['unexpected' => 'value']))->toThrow(InvalidArgumentException::class); }); -test('options resolve tool choice from the attribute', function () { +test('options resolve tool choice from the attribute', function (): void { $options = TextGenerationOptions::forAgent(new AttributeToolChoiceAgent); expect($options->toolChoice)->not->toBeNull() ->and($options->toolChoice->mode)->toBe(ToolChoice::required); }); -test('options resolve tool choice from the method over the attribute', function () { +test('options resolve tool choice from the method over the attribute', function (): void { $options = TextGenerationOptions::forAgent(new ToolChoiceAgent(ToolChoice::tool('custom_named_tool'))); expect($options->toolChoice->mode)->toBe(ToolChoice::tool) ->and($options->toolChoice->toolName)->toBe('custom_named_tool'); }); -test('options coerce a plain string from the method', function () { +test('options coerce a plain string from the method', function (): void { $options = TextGenerationOptions::forAgent(new ToolChoiceAgent('required')); expect($options->toolChoice->mode)->toBe(ToolChoice::required); }); -test('options leave tool choice null when the agent declares none', function () { +test('options leave tool choice null when the agent declares none', function (): void { expect(TextGenerationOptions::forAgent(new AssistantAgent)->toolChoice)->toBeNull(); expect(TextGenerationOptions::forAgent(new ToolChoiceAgent)->toolChoice)->toBeNull(); }); -test('forStep releases a forced tool choice after the first step', function () { +test('forStep releases a forced tool choice after the first step', function (): void { foreach ([ToolChoice::required, ToolChoice::tool] as $mode) { $options = new TextGenerationOptions( toolChoice: $mode === ToolChoice::tool ? ToolChoice::tool('calculator') : new ToolChoice($mode), @@ -82,7 +82,7 @@ } }); -test('forStep keeps auto and none tool choices on every step', function () { +test('forStep keeps auto and none tool choices on every step', function (): void { foreach ([ToolChoice::auto, ToolChoice::none] as $mode) { $options = new TextGenerationOptions(toolChoice: new ToolChoice($mode)); @@ -91,7 +91,7 @@ } }); -test('forStep preserves other options when releasing the tool choice', function () { +test('forStep preserves other options when releasing the tool choice', function (): void { $options = new TextGenerationOptions( maxSteps: 4, maxTokens: 256, @@ -109,7 +109,7 @@ ->and($stepped->topP)->toBe(0.9); }); -test('forStep is a no-op when no tool choice is set', function () { +test('forStep is a no-op when no tool choice is set', function (): void { $options = new TextGenerationOptions(maxSteps: 3); expect($options->forStep(2))->toBe($options); diff --git a/tests/Unit/Tools/McpServerToolTest.php b/tests/Unit/Tools/McpServerToolTest.php index f3292c4ce..d6ab5884f 100644 --- a/tests/Unit/Tools/McpServerToolTest.php +++ b/tests/Unit/Tools/McpServerToolTest.php @@ -11,11 +11,11 @@ use Tests\Fixtures\Mcp\FakeStreamingMcpServerTool; use Tests\Fixtures\Mcp\FakeStructuredMcpServerTool; -beforeEach(function () { +beforeEach(function (): void { Container::setInstance(new Container); }); -test('it detects mcp server tool primitives', function () { +test('it detects mcp server tool primitives', function (): void { expect([ McpServerTool::supports(new FakeMcpServerTool), McpServerTool::supports(new stdClass), @@ -25,7 +25,7 @@ ); }); -test('it exposes the tool name, description, and schema', function () { +test('it exposes the tool name, description, and schema', function (): void { $tool = new McpServerTool(new FakeMcpServerTool); expect($tool->name())->toBe('fake-mcp-server-tool'); @@ -49,7 +49,7 @@ ]); }); -test('it invokes the underlying tool and returns text content', function () { +test('it invokes the underlying tool and returns text content', function (): void { $serverTool = new FakeMcpServerTool; $tool = new McpServerTool($serverTool); @@ -59,7 +59,7 @@ expect($serverTool->invocations)->toBe([['city' => 'Paris']]); }); -test('it serializes structured tool responses as json', function () { +test('it serializes structured tool responses as json', function (): void { $tool = new McpServerTool(new FakeStructuredMcpServerTool); $result = $tool->handle(new Request(['city' => 'Paris'])); @@ -73,28 +73,28 @@ ]); }); -test('it surfaces tool errors with the standard prefix', function () { +test('it surfaces tool errors with the standard prefix', function (): void { $tool = new McpServerTool(new FakeErroringMcpServerTool); expect($tool->handle(new Request))->toBe('MCP tool error: Something went wrong.'); }); -test('it returns only the final yielded response and ignores notifications and intermediate updates', function () { +test('it returns only the final yielded response and ignores notifications and intermediate updates', function (): void { $tool = new McpServerTool(new FakeStreamingMcpServerTool); expect($tool->handle(new Request))->toBe('Third.'); }); -test('it returns only the final item from an array response', function () { +test('it returns only the final item from an array response', function (): void { $tool = new McpServerTool(new FakeArrayMcpServerTool); expect($tool->handle(new Request))->toBe('Third.'); }); -test('the mcp request binding is cleared after the call', function () { +test('the mcp request binding is cleared after the call', function (): void { $tool = new McpServerTool(new FakeMcpServerTool); $tool->handle(new Request(['city' => 'Paris'])); - expect(Container::getInstance()->bound('Laravel\\Mcp\\Request'))->toBeFalse(); + expect(Container::getInstance()->bound(Laravel\Mcp\Request::class))->toBeFalse(); }); diff --git a/tests/Unit/Tools/McpToolTest.php b/tests/Unit/Tools/McpToolTest.php index 2761bfbea..d325f4667 100644 --- a/tests/Unit/Tools/McpToolTest.php +++ b/tests/Unit/Tools/McpToolTest.php @@ -8,7 +8,7 @@ use Tests\Fixtures\Mcp\FakeMcpTool; use Tests\Fixtures\Mcp\FakeMcpToolResult; -test('it detects mcp client tool primitives', function () { +test('it detects mcp client tool primitives', function (): void { expect([ McpTool::supports(mcpTool(new FakeMcpClient)), McpTool::supports(new stdClass), @@ -18,7 +18,7 @@ ); }); -test('it does not own the mcp client lifecycle', function () { +test('it does not own the mcp client lifecycle', function (): void { $client = new FakeMcpClient; new McpTool(mcpTool($client)); @@ -29,7 +29,7 @@ ]); }); -test('it translates mcp input schemas to laravel tool schemas', function () { +test('it translates mcp input schemas to laravel tool schemas', function (): void { $tool = new McpTool(mcpTool(new FakeMcpClient, inputSchema: [ 'type' => 'object', 'properties' => [ @@ -111,7 +111,7 @@ ]); }); -test('it resolves $ref against $defs and merges sibling keys', function () { +test('it resolves $ref against $defs and merges sibling keys', function (): void { $tool = new McpTool(mcpTool(new FakeMcpClient, inputSchema: [ 'type' => 'object', 'properties' => [ @@ -144,7 +144,7 @@ ]); }); -test('it drops an unresolvable $ref instead of failing the whole tool', function () { +test('it drops an unresolvable $ref instead of failing the whole tool', function (): void { $tool = new McpTool(mcpTool(new FakeMcpClient, inputSchema: [ 'type' => 'object', 'properties' => [ @@ -164,7 +164,7 @@ expect($schema['properties']['name'])->toMatchArray(['type' => 'string']); }); -test('it keeps the properties of a nullable object root', function () { +test('it keeps the properties of a nullable object root', function (): void { $tool = new McpTool(mcpTool(new FakeMcpClient, inputSchema: [ 'anyOf' => [ [ @@ -182,7 +182,7 @@ expect($schema['properties']['query'])->toMatchArray(['type' => 'string']); }); -test('it marks fields nullable when anyOf or oneOf lists null after the typed branch', function () { +test('it marks fields nullable when anyOf or oneOf lists null after the typed branch', function (): void { $tool = new McpTool(mcpTool(new FakeMcpClient, inputSchema: [ 'type' => 'object', 'properties' => [ @@ -214,7 +214,7 @@ ]); }); -test('it returns text content from successful mcp calls', function () { +test('it returns text content from successful mcp calls', function (): void { $client = new FakeMcpClient; $tool = new McpTool(mcpTool($client, name: 'search')); @@ -232,7 +232,7 @@ ); }); -test('it prefers structured content from successful mcp calls', function () { +test('it prefers structured content from successful mcp calls', function (): void { $client = new FakeMcpClient; $tool = new McpTool(mcpTool($client, name: 'lookup')); @@ -252,7 +252,7 @@ ]); }); -test('it surfaces application level mcp errors as tool output', function () { +test('it surfaces application level mcp errors as tool output', function (): void { $client = new FakeMcpClient; $tool = new McpTool(mcpTool($client, name: 'lookup')); @@ -263,9 +263,9 @@ expect($tool->handle(new Request(['id' => 1])))->toBe('MCP tool error: Record not found.'); }); -test('it falls back to title then name when description is null', function () { +test('it falls back to title then name when description is null', function (): void { $withTitle = new McpTool(mcpTool(new FakeMcpClient, name: 'lookup', title: 'Lookup record', description: null)); - $withoutTitle = new McpTool(mcpTool(new FakeMcpClient, name: 'fallback_name', title: null, description: null)); + $withoutTitle = new McpTool(mcpTool(new FakeMcpClient, name: 'fallback_name', description: null)); expect([ $withTitle->description(),