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 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..724c6555f 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,74 @@ expect($class->providerOptions('foo'))->toBeNull(); }); + +test('providerOptions can be set without key using ProviderOption', function (): void { + $class = new PendingRequest; + + $class->withProviderOptions([new ProviderOption('key', 'value')]); + + expect($class->providerOptions('key'))->toBe('value'); +}); + +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; + + $reasoningOption = new class('value') extends ProviderOption + { + public function __construct(string $value) + { + parent::__construct('reasoning', $value); + } + }; + + $class->withProviderOptions([$reasoningOption]); + + 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 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); + + $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; + }); +});