From 888539e5df4eed48847d9ea4be4643f41cb6eec8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=C3=ADn=20Ma=C5=A1ek?= Date: Wed, 26 Nov 2025 19:30:13 +0100 Subject: [PATCH 1/3] feat(provider-options): add provider-specific option filtering --- src/Concerns/HasProviderOptions.php | 42 ++++++++++++++-- src/Providers/OpenAI/Handlers/Text.php | 10 +--- src/ValueObjects/ProviderOption.php | 37 ++++++++++++++ tests/Concerns/HasProviderOptionsTest.php | 59 +++++++++++++++++++++++ tests/Providers/OpenAI/TextTest.php | 38 ++++++++++++++- 5 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 src/ValueObjects/ProviderOption.php diff --git a/src/Concerns/HasProviderOptions.php b/src/Concerns/HasProviderOptions.php index 4b6cdce32..39c27ce51 100644 --- a/src/Concerns/HasProviderOptions.php +++ b/src/Concerns/HasProviderOptions.php @@ -2,17 +2,27 @@ namespace Prism\Prism\Concerns; +use Prism\Prism\ValueObjects\ProviderOption; + trait HasProviderOptions { - /** @var array */ + /** @var array */ protected array $providerOptions = []; /** - * @param array $options + * @param array $options */ public function withProviderOptions(array $options = []): self { - $this->providerOptions = $options; + $options = collect($options)->mapWithKeys(function ($value, $key): array { + $value = $value instanceof ProviderOption + ? $value + : new ProviderOption($key, $value); + + return [$value->key => $value]; + }); + + $this->providerOptions = $options->toArray(); return $this; } @@ -20,9 +30,31 @@ public function withProviderOptions(array $options = []): self public function providerOptions(?string $valuePath = null): mixed { if ($valuePath === null) { - return $this->providerOptions; + return array_map( + fn (ProviderOption $option): mixed => $option->getValue(), + $this->getProviderOptions(), + ); } - return data_get($this->providerOptions, $valuePath); + $paths = explode('.', $valuePath, 2); + $option = data_get($this->getProviderOptions(), array_first($paths)); + + if (is_null($option)) { + return null; + } + + return count($paths) > 1 + ? $option->getValue(array_last($paths)) + : $option->getValue(); + } + + private function getProviderOptions(): array + { + return array_filter( + $this->providerOptions, + fn (ProviderOption $option): bool => $option->acceptsProvider( + $this->providerKey ?? null, + ), + ); } } diff --git a/src/Providers/OpenAI/Handlers/Text.php b/src/Providers/OpenAI/Handlers/Text.php index 643332676..df420dd08 100644 --- a/src/Providers/OpenAI/Handlers/Text.php +++ b/src/Providers/OpenAI/Handlers/Text.php @@ -131,19 +131,11 @@ protected function sendRequest(Request $request): ClientResponse 'input' => (new MessageMap($request->messages(), $request->systemPrompts()))(), 'max_output_tokens' => $request->maxTokens(), ], Arr::whereNotNull([ + ...$request->providerOptions(), 'temperature' => $request->temperature(), 'top_p' => $request->topP(), - 'metadata' => $request->providerOptions('metadata'), 'tools' => $this->buildTools($request), 'tool_choice' => ToolChoiceMap::map($request->toolChoice()), - 'parallel_tool_calls' => $request->providerOptions('parallel_tool_calls'), - 'previous_response_id' => $request->providerOptions('previous_response_id'), - 'service_tier' => $request->providerOptions('service_tier'), - 'text' => $request->providerOptions('text_verbosity') ? [ - 'verbosity' => $request->providerOptions('text_verbosity'), - ] : null, - 'truncation' => $request->providerOptions('truncation'), - 'reasoning' => $request->providerOptions('reasoning'), ])) ); } diff --git a/src/ValueObjects/ProviderOption.php b/src/ValueObjects/ProviderOption.php new file mode 100644 index 000000000..24773986b --- /dev/null +++ b/src/ValueObjects/ProviderOption.php @@ -0,0 +1,37 @@ +value)) { + return $this->value; + } + + return Arr::get($this->value, $valuePath); + } + + public function acceptsProvider(Provider|string|null $provider): bool + { + if (is_null($provider) || is_null($this->provider)) { + return true; + } + + $providerKey = $provider instanceof Provider + ? $provider->value + : $provider; + + return $this->provider->value === $providerKey; + } +} diff --git a/tests/Concerns/HasProviderOptionsTest.php b/tests/Concerns/HasProviderOptionsTest.php index 7fabf7a17..910a15aa2 100644 --- a/tests/Concerns/HasProviderOptionsTest.php +++ b/tests/Concerns/HasProviderOptionsTest.php @@ -2,7 +2,9 @@ namespace Tests\Http; +use Prism\Prism\Enums\Provider; use Prism\Prism\Text\PendingRequest; +use Prism\Prism\ValueObjects\ProviderOption; test('providerOptions returns an array with all providerOptions if no valuePath is provided.', function (): void { $class = new PendingRequest; @@ -27,3 +29,60 @@ expect($class->providerOptions('foo'))->toBeNull(); }); + +test('providerOptions can be set using ProviderOption class', function (): void { + $class = new PendingRequest; + + $class->withProviderOptions([new ProviderOption('key', 'value')]); + + expect($class->providerOptions('key'))->toBe('value'); +}); + +test('providerOptions can be set using ProviderOption class and class key takes precedence', function (): void { + $class = new PendingRequest; + + $class->withProviderOptions(['key1' => new ProviderOption('key2', 'value')]); + + expect($class->providerOptions('key2'))->toBe('value'); +}); + +test('providerOptions can be set without key when using extended ProviderOption class', function (): void { + $class = new PendingRequest; + + $option = new class('value') extends ProviderOption + { + public function __construct(string $value) + { + parent::__construct('reasoning', $value); + } + }; + + $class->withProviderOptions(['key1' => $option]); + + expect($class->providerOptions('reasoning'))->toBe('value'); +}); + +test('providerOptions wont return ProviderOption for incorrect provider', function (): void { + $class = new PendingRequest; + $class->using(Provider::Anthropic); + + $class->withProviderOptions([new ProviderOption( + 'reasoning', + 'value', + Provider::OpenAI, + )]); + + expect($class->providerOptions('reasoning'))->toBeNull(); +}); + +test('providerOptions will always return ProviderOption when option does not specify provider', function (): void { + $class = new PendingRequest; + $class->using(Provider::Anthropic); + + $class->withProviderOptions([new ProviderOption( + 'reasoning', + 'value', + )]); + + expect($class->providerOptions('reasoning'))->toBe('value'); +}); diff --git a/tests/Providers/OpenAI/TextTest.php b/tests/Providers/OpenAI/TextTest.php index 9024b35ce..f64039347 100644 --- a/tests/Providers/OpenAI/TextTest.php +++ b/tests/Providers/OpenAI/TextTest.php @@ -15,6 +15,7 @@ use Prism\Prism\ValueObjects\Media\Document; use Prism\Prism\ValueObjects\MessagePartWithCitations; use Prism\Prism\ValueObjects\Messages\UserMessage; +use Prism\Prism\ValueObjects\ProviderOption; use Prism\Prism\ValueObjects\ProviderTool; use Prism\Prism\ValueObjects\ProviderToolCall; use Tests\Fixtures\FixtureResponse; @@ -442,13 +443,21 @@ 'openai/generate-text-with-a-prompt' ); - $textVerbosity = 'medium'; + $option = new class($textVerbosity = 'medium') extends ProviderOption + { + public function __construct(string $value) + { + parent::__construct('text', [ + 'verbosity' => $value, + ]); + } + }; Prism::text() ->using(Provider::OpenAI, 'gpt-4o') ->withPrompt('Who are you?') ->withProviderOptions([ - 'text_verbosity' => $textVerbosity, + $option, ]) ->asText(); @@ -741,3 +750,28 @@ expect($responseTwo->text)->toContain('Metcheck'); }); }); + +it('passes store parameter when specified', function (): void { + FixtureResponse::fakeResponseSequence( + 'v1/responses', + 'openai/generate-text-with-a-prompt' + ); + + $store = false; + + Prism::text() + ->using(Provider::OpenAI, 'gpt-4o') + ->withPrompt('Give me TLDR of this legal document') + ->withProviderOptions([ + 'store' => $store, + ]) + ->asText(); + + Http::assertSent(function (Request $request) use ($store): true { + $body = json_decode($request->body(), true); + + expect(data_get($body, 'store'))->toBe($store); + + return true; + }); +}); From d4b44e8972e26cde2b4b1586a8d901b69fe5a3d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=C3=ADn=20Ma=C5=A1ek?= Date: Wed, 26 Nov 2025 19:30:36 +0100 Subject: [PATCH 2/3] Docs --- docs/advanced/provider-interoperability.md | 43 ++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/advanced/provider-interoperability.md b/docs/advanced/provider-interoperability.md index dc5724562..59f9d4841 100644 --- a/docs/advanced/provider-interoperability.md +++ b/docs/advanced/provider-interoperability.md @@ -81,6 +81,49 @@ This approach can be especially helpful when you have complex or reusable provid > [!TIP] > The `whenProvider` method works with all request types in Prism including text, structured output, and embeddings requests. +## Using `ProviderOption` + +The `ProviderOption` class provides a convenient way to define provider-specific options. It allows you to specify which provider an option applies to, ensuring options are only used with their intended provider. +```php +Prism::text() + ->using(Provider::OpenAI, 'gpt-4o') + ->withPrompt('Who are you?') + ->withProviderOptions([ + new \Prism\Prism\ValueObjects\ProviderOption( + 'cacheType', + 'ephemeral', + Provider::Anthropic, + ), + ]) + ->asText(); +``` + +In this example, since OpenAI is used as the provider, the `cacheType` option is automatically skipped. + +### Custom Provider Options + +You can extend the `ProviderOption` class to create custom options with predefined keys and additional logic. + +```php +class AnthropicCacheType extends ProviderOption +{ + public function __construct(string $value) + { + parent::__construct('cacheType', $value, Provider::Anthropic); + } +} + +Prism::text() + ->using(Provider::OpenAI, 'gpt-4o') + ->withPrompt('Who are you?') + ->withProviderOptions([ + new AnthropicCacheType('ephemeral'), + ]) + ->asText(); +``` + +Like the previous example, the `cacheType` option is skipped since OpenAI is the active provider. + ## Best Practices ### Avoiding SystemMessages with Multiple Providers From 4f9b42a481de5d049562fce29cea4b5d0a9a0453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anton=C3=ADn=20Ma=C5=A1ek?= Date: Wed, 26 Nov 2025 20:44:57 +0100 Subject: [PATCH 3/3] Improve tests --- tests/Concerns/HasProviderOptionsTest.php | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/Concerns/HasProviderOptionsTest.php b/tests/Concerns/HasProviderOptionsTest.php index 910a15aa2..724c6555f 100644 --- a/tests/Concerns/HasProviderOptionsTest.php +++ b/tests/Concerns/HasProviderOptionsTest.php @@ -30,7 +30,7 @@ expect($class->providerOptions('foo'))->toBeNull(); }); -test('providerOptions can be set using ProviderOption class', function (): void { +test('providerOptions can be set without key using ProviderOption', function (): void { $class = new PendingRequest; $class->withProviderOptions([new ProviderOption('key', 'value')]); @@ -38,18 +38,19 @@ expect($class->providerOptions('key'))->toBe('value'); }); -test('providerOptions can be set using ProviderOption class and class key takes precedence', function (): void { +test('providerOptions can be set using ProviderOption and class key takes precedence', function (): void { $class = new PendingRequest; $class->withProviderOptions(['key1' => new ProviderOption('key2', 'value')]); expect($class->providerOptions('key2'))->toBe('value'); + expect($class->providerOptions('key'))->toBeNull(); }); test('providerOptions can be set without key when using extended ProviderOption class', function (): void { $class = new PendingRequest; - $option = new class('value') extends ProviderOption + $reasoningOption = new class('value') extends ProviderOption { public function __construct(string $value) { @@ -57,7 +58,7 @@ public function __construct(string $value) } }; - $class->withProviderOptions(['key1' => $option]); + $class->withProviderOptions([$reasoningOption]); expect($class->providerOptions('reasoning'))->toBe('value'); }); @@ -75,6 +76,19 @@ public function __construct(string $value) expect($class->providerOptions('reasoning'))->toBeNull(); }); +test('providerOptions returns ProviderOption for correct provider', function (): void { + $class = new PendingRequest; + $class->using(Provider::OpenAI); + + $class->withProviderOptions([new ProviderOption( + 'reasoning', + 'value', + Provider::OpenAI, + )]); + + expect($class->providerOptions('reasoning'))->toBe('value'); +}); + test('providerOptions will always return ProviderOption when option does not specify provider', function (): void { $class = new PendingRequest; $class->using(Provider::Anthropic);