From cf4cc6e291f352c6dc4a5e312450e7285c66d97e Mon Sep 17 00:00:00 2001 From: Muhammad Wildan Aldiansyah Date: Wed, 8 Apr 2026 23:25:32 +0700 Subject: [PATCH] fix(ToolMap): remove static duplicate parameters handling in mapping --- src/Providers/OpenRouter/Maps/ToolMap.php | 5 - tests/Providers/OpenRouter/ToolTest.php | 106 ++++++++++++++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/src/Providers/OpenRouter/Maps/ToolMap.php b/src/Providers/OpenRouter/Maps/ToolMap.php index 3d8f3dbf4..bff3656b5 100644 --- a/src/Providers/OpenRouter/Maps/ToolMap.php +++ b/src/Providers/OpenRouter/Maps/ToolMap.php @@ -30,11 +30,6 @@ public static function map(array $tools): array ]; })(), ] : [], - 'parameters' => [ - 'type' => 'object', - 'properties' => $tool->hasParameters() ? $tool->parametersAsArray() : (object) [], - 'required' => $tool->requiredParameters(), - ], ], 'strict' => $tool->providerOptions('strict'), ]), $tools); diff --git a/tests/Providers/OpenRouter/ToolTest.php b/tests/Providers/OpenRouter/ToolTest.php index be9ba6fa2..2c7f0929b 100644 --- a/tests/Providers/OpenRouter/ToolTest.php +++ b/tests/Providers/OpenRouter/ToolTest.php @@ -2,8 +2,14 @@ declare(strict_types=1); +use Illuminate\Http\Client\Request; +use Illuminate\Support\Facades\Http; +use Prism\Prism\Enums\Provider; +use Prism\Prism\Facades\Prism; +use Prism\Prism\Facades\Tool as ToolFacade; use Prism\Prism\Providers\OpenRouter\Maps\ToolMap; use Prism\Prism\Tool; +use Tests\Fixtures\FixtureResponse; it('maps tools', function (): void { $tool = (new Tool) @@ -31,6 +37,69 @@ ]]); }); +it('generates parameters only once from conditional spread', function (): void { + $withParams = (new Tool) + ->as('search') + ->for('Search the web') + ->withStringParameter('query', 'the search query') + ->withNumberParameter('limit', 'max results', required: false) + ->using(fn (): string => '[results]'); + + $withoutParams = (new Tool) + ->as('get_time') + ->for('Returns the current time') + ->using(fn (): string => now()->toISOString()); + + $mapped = ToolMap::map([$withParams, $withoutParams]); + + // Tool WITH parameters: parameters is generated once by the conditional spread + $searchFn = $mapped[0]['function']; + expect($searchFn)->toHaveKey('parameters'); + expect($searchFn['parameters'])->toBe([ + 'type' => 'object', + 'properties' => [ + 'query' => [ + 'description' => 'the search query', + 'type' => 'string', + ], + 'limit' => [ + 'description' => 'max results', + 'type' => 'number', + ], + ], + 'required' => ['query'], + ]); + + // Tool WITHOUT parameters: conditional spread adds nothing, + // and no static duplicate exists to add it back + $timeFn = $mapped[1]['function']; + expect($timeFn)->not->toHaveKey('parameters'); + expect(array_keys($timeFn))->toBe(['name', 'description']); +}); + +it('proves parameters is generated by conditional spread not static duplicate', function (): void { + // Mock a tool where hasParameters()=true but parametersAsArray()=[] + // KEY 1 (conditional spread) produces: 'properties' => new \stdClass (JSON: {}) + // KEY 2 (static duplicate) produces: 'properties' => [] (JSON: []) + // If KEY 2 overwrites KEY 1, properties will be [] not stdClass + + $tool = Mockery::mock(Tool::class); + $tool->shouldReceive('name')->andReturn('mock_tool'); + $tool->shouldReceive('description')->andReturn('A mock tool'); + $tool->shouldReceive('hasParameters')->andReturn(true); + $tool->shouldReceive('parametersAsArray')->andReturn([]); // empty + $tool->shouldReceive('requiredParameters')->andReturn([]); + $tool->shouldReceive('providerOptions')->with('strict')->andReturn(null); + + $mapped = ToolMap::map([$tool]); + $properties = $mapped[0]['function']['parameters']['properties']; + + // KEY 1 wraps empty properties in stdClass (serializes as JSON {}) + // KEY 2 would leave it as [] (serializes as JSON []) + // If this is stdClass, KEY 1 produced it. If array, KEY 2 overwrote it. + expect($properties)->toBeInstanceOf(stdClass::class, 'parameters.properties should be stdClass from conditional spread, not [] from static duplicate'); +}); + it('maps tools with strict mode', function (): void { $tool = (new Tool) ->as('search') @@ -60,3 +129,40 @@ 'strict' => true, ]]); }); + +it('sends correct tool payload to OpenRouter when streaming with parameter less tool', function (): void { + FixtureResponse::fakeStreamResponses('v1/chat/completions', 'openrouter/stream-text-with-empty-parameters-tools-when-using-gpt-5'); + + $timeTool = ToolFacade::as('time') + ->for('Get the current time') + ->using(fn (): string => '08:00:00'); + + $searchTool = ToolFacade::as('search') + ->for('Search the web') + ->withStringParameter('query', 'the search query') + ->using(fn (string $query): string => 'results'); + + $response = Prism::text() + ->using(Provider::OpenRouter, 'openai/gpt-5') + ->withTools([$timeTool, $searchTool]) + ->withMaxSteps(3) + ->withPrompt('What time is it?') + ->asStream(); + + Http::assertSent(function (Request $request): bool { + $tools = $request->data()['tools']; + + // Tool without parameters: no 'parameters' key in function + $timeFn = $tools[0]['function']; + expect($timeFn['name'])->toBe('time'); + expect($timeFn)->not->toHaveKey('parameters'); + + // Tool with parameters: has 'parameters' key + $searchFn = $tools[1]['function']; + expect($searchFn['name'])->toBe('search'); + expect($searchFn)->toHaveKey('parameters'); + expect($searchFn['parameters']['properties'])->toHaveKey('query'); + + return true; + }); +});