Skip to content

Commit ff073d1

Browse files
committed
Merge prism-php#1004 — fix(openrouter): remove duplicate parameters key overwriting conditional spread in ToolMap
2 parents ea5556c + cf4cc6e commit ff073d1

2 files changed

Lines changed: 106 additions & 5 deletions

File tree

src/Providers/OpenRouter/Maps/ToolMap.php

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,6 @@ public static function map(array $tools): array
3030
];
3131
})(),
3232
] : [],
33-
'parameters' => [
34-
'type' => 'object',
35-
'properties' => $tool->hasParameters() ? $tool->parametersAsArray() : (object) [],
36-
'required' => $tool->requiredParameters(),
37-
],
3833
],
3934
'strict' => $tool->providerOptions('strict'),
4035
]), $tools);

tests/Providers/OpenRouter/ToolTest.php

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@
22

33
declare(strict_types=1);
44

5+
use Illuminate\Http\Client\Request;
6+
use Illuminate\Support\Facades\Http;
7+
use Prism\Prism\Enums\Provider;
8+
use Prism\Prism\Facades\Prism;
9+
use Prism\Prism\Facades\Tool as ToolFacade;
510
use Prism\Prism\Providers\OpenRouter\Maps\ToolMap;
611
use Prism\Prism\Tool;
12+
use Tests\Fixtures\FixtureResponse;
713

814
it('maps tools', function (): void {
915
$tool = (new Tool)
@@ -31,6 +37,69 @@
3137
]]);
3238
});
3339

40+
it('generates parameters only once from conditional spread', function (): void {
41+
$withParams = (new Tool)
42+
->as('search')
43+
->for('Search the web')
44+
->withStringParameter('query', 'the search query')
45+
->withNumberParameter('limit', 'max results', required: false)
46+
->using(fn (): string => '[results]');
47+
48+
$withoutParams = (new Tool)
49+
->as('get_time')
50+
->for('Returns the current time')
51+
->using(fn (): string => now()->toISOString());
52+
53+
$mapped = ToolMap::map([$withParams, $withoutParams]);
54+
55+
// Tool WITH parameters: parameters is generated once by the conditional spread
56+
$searchFn = $mapped[0]['function'];
57+
expect($searchFn)->toHaveKey('parameters');
58+
expect($searchFn['parameters'])->toBe([
59+
'type' => 'object',
60+
'properties' => [
61+
'query' => [
62+
'description' => 'the search query',
63+
'type' => 'string',
64+
],
65+
'limit' => [
66+
'description' => 'max results',
67+
'type' => 'number',
68+
],
69+
],
70+
'required' => ['query'],
71+
]);
72+
73+
// Tool WITHOUT parameters: conditional spread adds nothing,
74+
// and no static duplicate exists to add it back
75+
$timeFn = $mapped[1]['function'];
76+
expect($timeFn)->not->toHaveKey('parameters');
77+
expect(array_keys($timeFn))->toBe(['name', 'description']);
78+
});
79+
80+
it('proves parameters is generated by conditional spread not static duplicate', function (): void {
81+
// Mock a tool where hasParameters()=true but parametersAsArray()=[]
82+
// KEY 1 (conditional spread) produces: 'properties' => new \stdClass (JSON: {})
83+
// KEY 2 (static duplicate) produces: 'properties' => [] (JSON: [])
84+
// If KEY 2 overwrites KEY 1, properties will be [] not stdClass
85+
86+
$tool = Mockery::mock(Tool::class);
87+
$tool->shouldReceive('name')->andReturn('mock_tool');
88+
$tool->shouldReceive('description')->andReturn('A mock tool');
89+
$tool->shouldReceive('hasParameters')->andReturn(true);
90+
$tool->shouldReceive('parametersAsArray')->andReturn([]); // empty
91+
$tool->shouldReceive('requiredParameters')->andReturn([]);
92+
$tool->shouldReceive('providerOptions')->with('strict')->andReturn(null);
93+
94+
$mapped = ToolMap::map([$tool]);
95+
$properties = $mapped[0]['function']['parameters']['properties'];
96+
97+
// KEY 1 wraps empty properties in stdClass (serializes as JSON {})
98+
// KEY 2 would leave it as [] (serializes as JSON [])
99+
// If this is stdClass, KEY 1 produced it. If array, KEY 2 overwrote it.
100+
expect($properties)->toBeInstanceOf(stdClass::class, 'parameters.properties should be stdClass from conditional spread, not [] from static duplicate');
101+
});
102+
34103
it('maps tools with strict mode', function (): void {
35104
$tool = (new Tool)
36105
->as('search')
@@ -60,3 +129,40 @@
60129
'strict' => true,
61130
]]);
62131
});
132+
133+
it('sends correct tool payload to OpenRouter when streaming with parameter less tool', function (): void {
134+
FixtureResponse::fakeStreamResponses('v1/chat/completions', 'openrouter/stream-text-with-empty-parameters-tools-when-using-gpt-5');
135+
136+
$timeTool = ToolFacade::as('time')
137+
->for('Get the current time')
138+
->using(fn (): string => '08:00:00');
139+
140+
$searchTool = ToolFacade::as('search')
141+
->for('Search the web')
142+
->withStringParameter('query', 'the search query')
143+
->using(fn (string $query): string => 'results');
144+
145+
$response = Prism::text()
146+
->using(Provider::OpenRouter, 'openai/gpt-5')
147+
->withTools([$timeTool, $searchTool])
148+
->withMaxSteps(3)
149+
->withPrompt('What time is it?')
150+
->asStream();
151+
152+
Http::assertSent(function (Request $request): bool {
153+
$tools = $request->data()['tools'];
154+
155+
// Tool without parameters: no 'parameters' key in function
156+
$timeFn = $tools[0]['function'];
157+
expect($timeFn['name'])->toBe('time');
158+
expect($timeFn)->not->toHaveKey('parameters');
159+
160+
// Tool with parameters: has 'parameters' key
161+
$searchFn = $tools[1]['function'];
162+
expect($searchFn['name'])->toBe('search');
163+
expect($searchFn)->toHaveKey('parameters');
164+
expect($searchFn['parameters']['properties'])->toHaveKey('query');
165+
166+
return true;
167+
});
168+
});

0 commit comments

Comments
 (0)