Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/Providers/Gemini/Maps/SchemaMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, mixed> $properties
* @return array<string, mixed>
*/
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) {
Expand Down
60 changes: 60 additions & 0 deletions tests/Providers/Gemini/SchemaMapTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down