diff --git a/src/Providers/Gemini/Maps/SchemaMap.php b/src/Providers/Gemini/Maps/SchemaMap.php index 7c9f80a14..b85b9477f 100644 --- a/src/Providers/Gemini/Maps/SchemaMap.php +++ b/src/Providers/Gemini/Maps/SchemaMap.php @@ -55,6 +55,9 @@ public function toArray(): array array_filter([ ...$schemaArray, 'type' => $this->mapType(), + 'properties' => isset($schemaArray['properties']) + ? $this->normalizeProperties($schemaArray['properties']) + : null, ]), array_filter([ 'items' => property_exists($this->schema, 'items') && $this->schema->items @@ -75,6 +78,41 @@ public function toArray(): array ); } + /** + * Normalize properties that use JSON Schema array-type notation for nullability + * (e.g. ["integer", "null"]) into Gemini's supported format ("nullable": true). + * + * This handles schemas passed as raw arrays (e.g. from Laravel's JsonSchema + * serializer) which bypass Prism's native schema objects and are never run + * through the per-property SchemaMap normalization. + * + * @param array $properties + * @return array + */ + protected function normalizeProperties(array $properties): array + { + return array_map(function (array $property): array { + if (! is_array($property['type'] ?? null)) { + return $property; + } + + $types = $property['type']; + $nullIndex = array_search('null', $types, true); + + if ($nullIndex === false) { + return $property; + } + + unset($types[$nullIndex]); + $remaining = array_values($types); + + $property['type'] = count($remaining) === 1 ? $remaining[0] : $remaining; + $property['nullable'] = true; + + return $property; + }, $properties); + } + protected function mapType(): string { if ($this->schema instanceof ArraySchema) { diff --git a/tests/Providers/Gemini/SchemaMapTest.php b/tests/Providers/Gemini/SchemaMapTest.php index 3e4e317fc..ca0c2fadb 100644 --- a/tests/Providers/Gemini/SchemaMapTest.php +++ b/tests/Providers/Gemini/SchemaMapTest.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use Prism\Prism\Contracts\HasSchemaType; +use Prism\Prism\Contracts\Schema; use Prism\Prism\Providers\Gemini\Maps\SchemaMap; use Prism\Prism\Schema\ArraySchema; use Prism\Prism\Schema\BooleanSchema; @@ -122,6 +124,64 @@ ]); }); +it('normalizes array-type nullable notation in properties to Gemini nullable format', function (): void { + // Simulates schemas passed as raw arrays (e.g. via Laravel AI SDK's ObjectSchema + // which uses Illuminate's JsonSchema serializer). That serializer produces + // ["integer", "null"] for nullable fields — a valid JSON Schema draft-4 format + // that Gemini does not support. Gemini requires "nullable": true instead. + $schema = new class implements HasSchemaType, Schema + { + public function name(): string + { + return 'schema_definition'; + } + + public function schemaType(): string + { + return 'object'; + } + + public function toArray(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'videoViews' => ['type' => 'integer', 'description' => 'Total video views'], + 'averageWatchTimeInSeconds' => ['type' => ['integer', 'null'], 'description' => 'Average watch time'], + 'score' => ['type' => ['number', 'null'], 'description' => 'Score'], + 'label' => ['type' => ['string', 'null'], 'description' => 'Label'], + ], + 'required' => ['videoViews'], + ]; + } + }; + + $map = (new SchemaMap($schema))->toArray(); + + expect($map['properties']['videoViews'])->toBe([ + 'type' => 'integer', + 'description' => 'Total video views', + ]); + + expect($map['properties']['averageWatchTimeInSeconds'])->toBe([ + 'type' => 'integer', + 'description' => 'Average watch time', + 'nullable' => true, + ]); + + expect($map['properties']['score'])->toBe([ + 'type' => 'number', + 'description' => 'Score', + 'nullable' => true, + ]); + + expect($map['properties']['label'])->toBe([ + 'type' => 'string', + 'description' => 'Label', + 'nullable' => true, + ]); +}); + it('does not map a raw schema', function (): void { $map = (new SchemaMap(new RawSchema( 'schema',