diff --git a/src/Files/Base64Document.php b/src/Files/Base64Document.php index 626e047c0..1617ffaef 100644 --- a/src/Files/Base64Document.php +++ b/src/Files/Base64Document.php @@ -4,6 +4,7 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Http\UploadedFile; +use InvalidArgumentException; use JsonSerializable; use Laravel\Ai\Contracts\Files\StorableFile; use Laravel\Ai\Files\Concerns\CanBeUploadedToProvider; @@ -14,6 +15,10 @@ class Base64Document extends Document implements Arrayable, JsonSerializable, St public function __construct(public string $base64, ?string $mimeType = null) { + if (blank($base64)) { + throw new InvalidArgumentException('Base64 document content cannot be empty.'); + } + $this->mime = $mimeType; } diff --git a/src/Files/Base64Image.php b/src/Files/Base64Image.php index 9ea2e61f2..4849a5449 100644 --- a/src/Files/Base64Image.php +++ b/src/Files/Base64Image.php @@ -3,6 +3,7 @@ namespace Laravel\Ai\Files; use Illuminate\Contracts\Support\Arrayable; +use InvalidArgumentException; use JsonSerializable; use Laravel\Ai\Contracts\Files\StorableFile; use Laravel\Ai\Files\Concerns\CanBeUploadedToProvider; @@ -13,6 +14,10 @@ class Base64Image extends Image implements Arrayable, JsonSerializable, Storable public function __construct(public string $base64, ?string $mimeType = null) { + if (blank($base64)) { + throw new InvalidArgumentException('Base64 image content cannot be empty.'); + } + $this->mime = $mimeType; } diff --git a/tests/Feature/EmbeddingsFakeTest.php b/tests/Feature/EmbeddingsFakeTest.php index 26f85d175..9a1695dba 100644 --- a/tests/Feature/EmbeddingsFakeTest.php +++ b/tests/Feature/EmbeddingsFakeTest.php @@ -68,6 +68,28 @@ expect($response)->toHaveCount(3); }); + test('can iterate over response', function () { + Embeddings::fake([ + [ + array_fill(0, 3, 0.1), + array_fill(0, 3, 0.2), + ], + ]); + + $response = Embeddings::for(['Hello', 'World'])->dimensions(3)->generate(); + + $embeddings = []; + + foreach ($response as $embedding) { + $embeddings[] = $embedding; + } + + expect($embeddings)->toEqual([ + array_fill(0, 3, 0.1), + array_fill(0, 3, 0.2), + ]); + }); + test('can fake embeddings with custom response', function () { $customEmbedding = array_fill(0, 100, 0.5); diff --git a/tests/Feature/Providers/Anthropic/ToolMappingTest.php b/tests/Feature/Providers/Anthropic/ToolMappingTest.php index 7d8a03352..74b430dfc 100644 --- a/tests/Feature/Providers/Anthropic/ToolMappingTest.php +++ b/tests/Feature/Providers/Anthropic/ToolMappingTest.php @@ -2,6 +2,7 @@ use Illuminate\Support\Facades\Http; use Laravel\Ai\Providers\Tools\FileSearch; +use Laravel\Ai\Providers\Tools\WebFetch; use Laravel\Ai\Providers\Tools\WebSearch; use Tests\Fixtures\Agents\NamedToolAgent; use Tests\Fixtures\Agents\ToolUsingAgent; @@ -93,6 +94,50 @@ }); }); +test('web fetch tool sends allowed_domains', function () { + Http::fake([ + 'api.anthropic.com/*' => $this->fakeTextResponse('ok'), + ]); + + agent(tools: [(new WebFetch)->allow(['laravel.com', 'php.net'])]) + ->prompt('Fetch', provider: 'anthropic'); + + Http::assertSent(function ($request) { + $tool = collect($request->data()['tools'] ?? [])->firstWhere('name', 'web_fetch'); + + return data_get($tool, 'type') === 'web_fetch_20250910' + && data_get($tool, 'allowed_domains') === ['laravel.com', 'php.net']; + }); +}); + +test('web fetch tool defaults max_uses to ten', function () { + Http::fake([ + 'api.anthropic.com/*' => $this->fakeTextResponse('ok'), + ]); + + agent(tools: [new WebFetch])->prompt('Fetch', provider: 'anthropic'); + + Http::assertSent(function ($request) { + $tool = collect($request->data()['tools'] ?? [])->firstWhere('name', 'web_fetch'); + + return data_get($tool, 'max_uses') === 10; + }); +}); + +test('web fetch tool forwards a custom max_uses', function () { + Http::fake([ + 'api.anthropic.com/*' => $this->fakeTextResponse('ok'), + ]); + + agent(tools: [(new WebFetch)->max(3)])->prompt('Fetch', provider: 'anthropic'); + + Http::assertSent(function ($request) { + $tool = collect($request->data()['tools'] ?? [])->firstWhere('name', 'web_fetch'); + + return data_get($tool, 'max_uses') === 3; + }); +}); + test('empty schema still includes input schema with type object', function () { Http::fake([ 'api.anthropic.com/*' => $this->fakeTextResponse('The number is 42'), diff --git a/tests/Unit/Files/Base64DocumentTest.php b/tests/Unit/Files/Base64DocumentTest.php new file mode 100644 index 000000000..8baede19c --- /dev/null +++ b/tests/Unit/Files/Base64DocumentTest.php @@ -0,0 +1,7 @@ +throws(InvalidArgumentException::class, 'Base64 document content cannot be empty.'); diff --git a/tests/Unit/Files/Base64ImageTest.php b/tests/Unit/Files/Base64ImageTest.php new file mode 100644 index 000000000..d1504b2c9 --- /dev/null +++ b/tests/Unit/Files/Base64ImageTest.php @@ -0,0 +1,7 @@ +throws(InvalidArgumentException::class, 'Base64 image content cannot be empty.'); diff --git a/tests/Unit/Gateway/Bedrock/BedrockTextGatewayTest.php b/tests/Unit/Gateway/Bedrock/BedrockTextGatewayTest.php index d480fd710..faaba2ebf 100644 --- a/tests/Unit/Gateway/Bedrock/BedrockTextGatewayTest.php +++ b/tests/Unit/Gateway/Bedrock/BedrockTextGatewayTest.php @@ -311,17 +311,17 @@ public function schema(JsonSchema $schema): array test('document format maps common mime types', function () { $gateway = textGateway(); - expect($gateway->callGetDocumentFormat(new Base64Document('', 'application/pdf')))->toBe('pdf'); - expect($gateway->callGetDocumentFormat(new Base64Document('', 'text/csv')))->toBe('csv'); - expect($gateway->callGetDocumentFormat(new Base64Document('', 'application/msword')))->toBe('doc'); - expect($gateway->callGetDocumentFormat(new Base64Document('', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')))->toBe('docx'); - expect($gateway->callGetDocumentFormat(new Base64Document('', 'application/vnd.ms-excel')))->toBe('xls'); - expect($gateway->callGetDocumentFormat(new Base64Document('', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')))->toBe('xlsx'); - expect($gateway->callGetDocumentFormat(new Base64Document('', 'text/html')))->toBe('html'); - expect($gateway->callGetDocumentFormat(new Base64Document('', 'text/markdown')))->toBe('md'); - expect($gateway->callGetDocumentFormat(new Base64Document('', 'text/x-markdown')))->toBe('md'); - expect($gateway->callGetDocumentFormat(new Base64Document('', 'text/plain; charset=utf-8')))->toBe('txt'); - expect($gateway->callGetDocumentFormat(new Base64Document('', null)))->toBeNull(); + expect($gateway->callGetDocumentFormat(new Base64Document(base64_encode('doc-bytes'), 'application/pdf')))->toBe('pdf'); + expect($gateway->callGetDocumentFormat(new Base64Document(base64_encode('doc-bytes'), 'text/csv')))->toBe('csv'); + expect($gateway->callGetDocumentFormat(new Base64Document(base64_encode('doc-bytes'), 'application/msword')))->toBe('doc'); + expect($gateway->callGetDocumentFormat(new Base64Document(base64_encode('doc-bytes'), 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')))->toBe('docx'); + expect($gateway->callGetDocumentFormat(new Base64Document(base64_encode('doc-bytes'), 'application/vnd.ms-excel')))->toBe('xls'); + expect($gateway->callGetDocumentFormat(new Base64Document(base64_encode('doc-bytes'), 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')))->toBe('xlsx'); + expect($gateway->callGetDocumentFormat(new Base64Document(base64_encode('doc-bytes'), 'text/html')))->toBe('html'); + 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(); }); test('user message with base64 image attachment produces image block', function () { @@ -380,20 +380,20 @@ public function schema(JsonSchema $schema): array test('image format maps common image mime types', function () { $gateway = textGateway(); - expect($gateway->callGetImageFormat(new Base64Image('', 'image/png')))->toBe('png'); - expect($gateway->callGetImageFormat(new Base64Image('', 'image/jpeg')))->toBe('jpeg'); - expect($gateway->callGetImageFormat(new Base64Image('', 'image/jpg')))->toBe('jpeg'); - expect($gateway->callGetImageFormat(new Base64Image('', 'image/gif')))->toBe('gif'); - expect($gateway->callGetImageFormat(new Base64Image('', 'image/webp')))->toBe('webp'); + expect($gateway->callGetImageFormat(new Base64Image(base64_encode('image-bytes'), 'image/png')))->toBe('png'); + expect($gateway->callGetImageFormat(new Base64Image(base64_encode('image-bytes'), 'image/jpeg')))->toBe('jpeg'); + expect($gateway->callGetImageFormat(new Base64Image(base64_encode('image-bytes'), 'image/jpg')))->toBe('jpeg'); + expect($gateway->callGetImageFormat(new Base64Image(base64_encode('image-bytes'), 'image/gif')))->toBe('gif'); + expect($gateway->callGetImageFormat(new Base64Image(base64_encode('image-bytes'), 'image/webp')))->toBe('webp'); }); test('image format throws when mime type is unsupported', function () { - expect(fn () => textGateway()->callGetImageFormat(new Base64Image('', 'image/unsupported'))) + 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 () { - expect(fn () => textGateway()->callGetImageFormat(new Base64Image('', null))) + expect(fn () => textGateway()->callGetImageFormat(new Base64Image(base64_encode('image-bytes'), null))) ->toThrow(InvalidArgumentException::class, 'Unable to determine MIME type'); });