From 7e12e4b230d8ea467f41ee936dba0fbd1483dbf5 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 25 Aug 2026 22:16:38 -0600 Subject: [PATCH 1/5] Add deferred tool loading primitives --- src/Builders/PromptBuilder.php | 13 ++ src/Messages/DTO/MessagePart.php | 52 ++++++- src/Messages/DTO/ProviderData.php | 129 ++++++++++++++++++ src/Messages/Enums/MessagePartTypeEnum.php | 7 + src/Providers/Models/DTO/ModelConfig.php | 43 ++++++ .../Models/DTO/ModelRequirements.php | 12 ++ src/Providers/Models/Enums/OptionEnum.php | 2 + src/Tools/DTO/FunctionDeclaration.php | 42 +++++- tests/unit/Builders/PromptBuilderTest.php | 22 +++ tests/unit/Messages/DTO/MessagePartTest.php | 60 +++++++- tests/unit/Messages/DTO/ProviderDataTest.php | 54 ++++++++ .../Enums/MessagePartTypeEnumTest.php | 5 + .../Providers/Models/DTO/ModelConfigTest.php | 11 ++ .../Models/DTO/ModelRequirementsTest.php | 55 ++++++++ .../Providers/Models/Enums/OptionEnumTest.php | 3 + .../Tools/DTO/FunctionDeclarationTest.php | 21 +++ 16 files changed, 521 insertions(+), 10 deletions(-) create mode 100644 src/Messages/DTO/ProviderData.php create mode 100644 tests/unit/Messages/DTO/ProviderDataTest.php diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 538392db..2033725c 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -331,6 +331,19 @@ public function usingFunctionDeclarations(FunctionDeclaration ...$functionDeclar return $this; } + /** + * Enables native tool search for deferred function declarations. + * + * @since 1.5.0 + * + * @return self + */ + public function usingToolSearch(): self + { + $this->modelConfig->setToolSearch(true); + return $this; + } + /** * Sets the presence penalty for generation. * diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index 228471be..358e3e82 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -24,6 +24,7 @@ * @phpstan-import-type FileArrayShape from File * @phpstan-import-type FunctionCallArrayShape from FunctionCall * @phpstan-import-type FunctionResponseArrayShape from FunctionResponse + * @phpstan-import-type ProviderDataArrayShape from ProviderData * * @phpstan-type MessagePartArrayShape array{ * channel: string, @@ -32,7 +33,8 @@ * text?: string, * file?: FileArrayShape, * functionCall?: FunctionCallArrayShape, - * functionResponse?: FunctionResponseArrayShape + * functionResponse?: FunctionResponseArrayShape, + * providerData?: ProviderDataArrayShape * } * * @extends AbstractDataTransferObject @@ -46,6 +48,7 @@ class MessagePart extends AbstractDataTransferObject public const KEY_FILE = 'file'; public const KEY_FUNCTION_CALL = 'functionCall'; public const KEY_FUNCTION_RESPONSE = 'functionResponse'; + public const KEY_PROVIDER_DATA = 'providerData'; /** * @var MessagePartChannelEnum The channel this message part belongs to. @@ -82,6 +85,11 @@ class MessagePart extends AbstractDataTransferObject */ private ?FunctionResponse $functionResponse = null; + /** + * @var ProviderData|null Opaque provider-native data (when type is PROVIDER_DATA). + */ + private ?ProviderData $providerData = null; + /** * Constructor that accepts various content types and infers the message part type. * @@ -109,12 +117,15 @@ public function __construct($content, ?MessagePartChannelEnum $channel = null, ? } elseif ($content instanceof FunctionResponse) { $this->type = MessagePartTypeEnum::functionResponse(); $this->functionResponse = $content; + } elseif ($content instanceof ProviderData) { + $this->type = MessagePartTypeEnum::providerData(); + $this->providerData = $content; } else { $type = is_object($content) ? get_class($content) : gettype($content); throw new InvalidArgumentException( sprintf( 'Unsupported content type %s. Expected string, File, ' - . 'FunctionCall, or FunctionResponse.', + . 'FunctionCall, FunctionResponse, or ProviderData.', $type ) ); @@ -205,6 +216,18 @@ public function getFunctionResponse(): ?FunctionResponse return $this->functionResponse; } + /** + * Gets the opaque provider-native data. + * + * @since 1.5.0 + * + * @return ProviderData|null The provider-native data or null if not a provider data part. + */ + public function getProviderData(): ?ProviderData + { + return $this->providerData; + } + /** * {@inheritDoc} * @@ -284,6 +307,20 @@ public static function getJsonSchema(): array 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_RESPONSE], 'additionalProperties' => false, ], + [ + 'type' => 'object', + 'properties' => [ + self::KEY_CHANNEL => $channelSchema, + self::KEY_TYPE => [ + 'type' => 'string', + 'const' => MessagePartTypeEnum::providerData()->value, + ], + self::KEY_PROVIDER_DATA => ProviderData::getJsonSchema(), + self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema, + ], + 'required' => [self::KEY_TYPE, self::KEY_PROVIDER_DATA], + 'additionalProperties' => false, + ], ], ]; } @@ -310,9 +347,11 @@ public function toArray(): array $data[self::KEY_FUNCTION_CALL] = $this->functionCall->toArray(); } elseif ($this->functionResponse !== null) { $data[self::KEY_FUNCTION_RESPONSE] = $this->functionResponse->toArray(); + } elseif ($this->providerData !== null) { + $data[self::KEY_PROVIDER_DATA] = $this->providerData->toArray(); } else { throw new RuntimeException( - 'MessagePart requires one of: text, file, functionCall, or functionResponse. ' + 'MessagePart requires one of: text, file, functionCall, functionResponse, or providerData. ' . 'This should not be a possible condition.' ); } @@ -352,9 +391,11 @@ public static function fromArray(array $array): self $channel, $thoughtSignature ); + } elseif (isset($array[self::KEY_PROVIDER_DATA])) { + return new self(ProviderData::fromArray($array[self::KEY_PROVIDER_DATA]), $channel, $thoughtSignature); } else { throw new InvalidArgumentException( - 'MessagePart requires one of: text, file, functionCall, or functionResponse.' + 'MessagePart requires one of: text, file, functionCall, functionResponse, or providerData.' ); } } @@ -378,5 +419,8 @@ public function __clone() if ($this->functionResponse !== null) { $this->functionResponse = clone $this->functionResponse; } + if ($this->providerData !== null) { + $this->providerData = clone $this->providerData; + } } } diff --git a/src/Messages/DTO/ProviderData.php b/src/Messages/DTO/ProviderData.php new file mode 100644 index 00000000..3d5e871e --- /dev/null +++ b/src/Messages/DTO/ProviderData.php @@ -0,0 +1,129 @@ + + * } + * + * @extends AbstractDataTransferObject + */ +class ProviderData extends AbstractDataTransferObject +{ + public const KEY_PROVIDER_ID = 'providerId'; + public const KEY_DATA = 'data'; + + /** + * @var string The identifier of the provider that owns this data. + */ + private string $providerId; + + /** + * @var array The opaque provider-native data. + */ + private array $data; + + /** + * Constructor. + * + * @since 1.5.0 + * + * @param string $providerId The identifier of the provider that owns this data. + * @param array $data The opaque provider-native data. + */ + public function __construct(string $providerId, array $data) + { + $this->providerId = $providerId; + $this->data = $data; + } + + /** + * Gets the identifier of the provider that owns this data. + * + * @since 1.5.0 + * + * @return string The provider identifier. + */ + public function getProviderId(): string + { + return $this->providerId; + } + + /** + * Gets the opaque provider-native data. + * + * @since 1.5.0 + * + * @return array The provider-native data. + */ + public function getData(): array + { + return $this->data; + } + + /** + * {@inheritDoc} + * + * @since 1.5.0 + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + self::KEY_PROVIDER_ID => [ + 'type' => 'string', + 'description' => 'The identifier of the provider that owns this data.', + ], + self::KEY_DATA => [ + 'type' => 'object', + 'additionalProperties' => true, + 'description' => 'Opaque provider-native message data.', + ], + ], + 'required' => [self::KEY_PROVIDER_ID, self::KEY_DATA], + 'additionalProperties' => false, + ]; + } + + /** + * {@inheritDoc} + * + * @since 1.5.0 + * + * @return ProviderDataArrayShape + */ + public function toArray(): array + { + return [ + self::KEY_PROVIDER_ID => $this->providerId, + self::KEY_DATA => $this->data, + ]; + } + + /** + * {@inheritDoc} + * + * @since 1.5.0 + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [self::KEY_PROVIDER_ID, self::KEY_DATA]); + + return new self($array[self::KEY_PROVIDER_ID], $array[self::KEY_DATA]); + } +} diff --git a/src/Messages/Enums/MessagePartTypeEnum.php b/src/Messages/Enums/MessagePartTypeEnum.php index ed7b7064..6466555f 100644 --- a/src/Messages/Enums/MessagePartTypeEnum.php +++ b/src/Messages/Enums/MessagePartTypeEnum.php @@ -15,10 +15,12 @@ * @method static self file() Creates an instance for FILE type. * @method static self functionCall() Creates an instance for FUNCTION_CALL type. * @method static self functionResponse() Creates an instance for FUNCTION_RESPONSE type. + * @method static self providerData() Creates an instance for PROVIDER_DATA type. * @method bool isText() Checks if the type is TEXT. * @method bool isFile() Checks if the type is FILE. * @method bool isFunctionCall() Checks if the type is FUNCTION_CALL. * @method bool isFunctionResponse() Checks if the type is FUNCTION_RESPONSE. + * @method bool isProviderData() Checks if the type is PROVIDER_DATA. */ class MessagePartTypeEnum extends AbstractEnum { @@ -41,4 +43,9 @@ class MessagePartTypeEnum extends AbstractEnum * Function response. */ public const FUNCTION_RESPONSE = 'function_response'; + + /** + * Opaque provider-native message data. + */ + public const PROVIDER_DATA = 'provider_data'; } diff --git a/src/Providers/Models/DTO/ModelConfig.php b/src/Providers/Models/DTO/ModelConfig.php index fad995d0..beb97559 100644 --- a/src/Providers/Models/DTO/ModelConfig.php +++ b/src/Providers/Models/DTO/ModelConfig.php @@ -38,6 +38,7 @@ * logprobs?: bool, * topLogprobs?: int, * functionDeclarations?: list, + * toolSearch?: bool, * webSearch?: WebSearchArrayShape, * outputFileType?: string, * outputMimeType?: string, @@ -66,6 +67,7 @@ class ModelConfig extends AbstractDataTransferObject public const KEY_LOGPROBS = 'logprobs'; public const KEY_TOP_LOGPROBS = 'topLogprobs'; public const KEY_FUNCTION_DECLARATIONS = 'functionDeclarations'; + public const KEY_TOOL_SEARCH = 'toolSearch'; public const KEY_WEB_SEARCH = 'webSearch'; public const KEY_OUTPUT_FILE_TYPE = 'outputFileType'; public const KEY_OUTPUT_MIME_TYPE = 'outputMimeType'; @@ -148,6 +150,11 @@ class ModelConfig extends AbstractDataTransferObject */ protected ?array $functionDeclarations = null; + /** + * @var bool|null Whether native tool search should be enabled for deferred function declarations. + */ + protected ?bool $toolSearch = null; + /** * @var WebSearch|null Web search configuration for the model. */ @@ -553,6 +560,30 @@ public function getFunctionDeclarations(): ?array return $this->functionDeclarations; } + /** + * Sets whether native tool search should be enabled for deferred function declarations. + * + * @since 1.5.0 + * + * @param bool $toolSearch Whether native tool search should be enabled. + */ + public function setToolSearch(bool $toolSearch): void + { + $this->toolSearch = $toolSearch; + } + + /** + * Gets whether native tool search should be enabled for deferred function declarations. + * + * @since 1.5.0 + * + * @return bool|null Whether native tool search should be enabled, or null if not configured. + */ + public function getToolSearch(): ?bool + { + return $this->toolSearch; + } + /** * Sets the web search configuration. * @@ -921,6 +952,10 @@ public static function getJsonSchema(): array 'items' => FunctionDeclaration::getJsonSchema(), 'description' => 'Function declarations available to the model.', ], + self::KEY_TOOL_SEARCH => [ + 'type' => 'boolean', + 'description' => 'Whether native tool search is enabled for deferred function declarations.', + ], self::KEY_WEB_SEARCH => WebSearch::getJsonSchema(), self::KEY_OUTPUT_FILE_TYPE => [ 'type' => 'string', @@ -1038,6 +1073,10 @@ static function (FunctionDeclaration $functionDeclaration): array { ); } + if ($this->toolSearch !== null) { + $data[self::KEY_TOOL_SEARCH] = $this->toolSearch; + } + if ($this->webSearch !== null) { $data[self::KEY_WEB_SEARCH] = $this->webSearch->toArray(); } @@ -1147,6 +1186,10 @@ static function (array $functionDeclarationData): FunctionDeclaration { )); } + if (isset($array[self::KEY_TOOL_SEARCH])) { + $config->setToolSearch($array[self::KEY_TOOL_SEARCH]); + } + if (isset($array[self::KEY_WEB_SEARCH])) { $config->setWebSearch(WebSearch::fromArray($array[self::KEY_WEB_SEARCH])); } diff --git a/src/Providers/Models/DTO/ModelRequirements.php b/src/Providers/Models/DTO/ModelRequirements.php index 492e5fd8..773127ff 100644 --- a/src/Providers/Models/DTO/ModelRequirements.php +++ b/src/Providers/Models/DTO/ModelRequirements.php @@ -382,6 +382,18 @@ private static function toRequiredOptions(ModelConfig $modelConfig): array $requiredOptions[] = new RequiredOption(OptionEnum::functionDeclarations(), true); } + $requiresToolSearch = $modelConfig->getToolSearch() === true; + foreach ($modelConfig->getFunctionDeclarations() ?? [] as $functionDeclaration) { + if ($functionDeclaration->isLoadingDeferred()) { + $requiresToolSearch = true; + break; + } + } + + if ($requiresToolSearch) { + $requiredOptions[] = new RequiredOption(OptionEnum::toolSearch(), true); + } + if ($modelConfig->getWebSearch() !== null) { $requiredOptions[] = new RequiredOption(OptionEnum::webSearch(), true); } diff --git a/src/Providers/Models/Enums/OptionEnum.php b/src/Providers/Models/Enums/OptionEnum.php index dde22ae2..40cd82ce 100644 --- a/src/Providers/Models/Enums/OptionEnum.php +++ b/src/Providers/Models/Enums/OptionEnum.php @@ -37,6 +37,7 @@ * @method static self stopSequences() Creates an instance for STOP_SEQUENCES option. * @method static self systemInstruction() Creates an instance for SYSTEM_INSTRUCTION option. * @method static self temperature() Creates an instance for TEMPERATURE option. + * @method static self toolSearch() Creates an instance for TOOL_SEARCH option. * @method static self topK() Creates an instance for TOP_K option. * @method static self topLogprobs() Creates an instance for TOP_LOGPROBS option. * @method static self topP() Creates an instance for TOP_P option. @@ -59,6 +60,7 @@ * @method bool isStopSequences() Checks if the option is STOP_SEQUENCES. * @method bool isSystemInstruction() Checks if the option is SYSTEM_INSTRUCTION. * @method bool isTemperature() Checks if the option is TEMPERATURE. + * @method bool isToolSearch() Checks if the option is TOOL_SEARCH. * @method bool isTopK() Checks if the option is TOP_K. * @method bool isTopLogprobs() Checks if the option is TOP_LOGPROBS. * @method bool isTopP() Checks if the option is TOP_P. diff --git a/src/Tools/DTO/FunctionDeclaration.php b/src/Tools/DTO/FunctionDeclaration.php index 5b08ce80..545ec034 100644 --- a/src/Tools/DTO/FunctionDeclaration.php +++ b/src/Tools/DTO/FunctionDeclaration.php @@ -17,7 +17,8 @@ * @phpstan-type FunctionDeclarationArrayShape array{ * name: string, * description: string, - * parameters?: array + * parameters?: array, + * deferLoading?: bool * } * * @extends AbstractDataTransferObject @@ -27,6 +28,7 @@ class FunctionDeclaration extends AbstractDataTransferObject public const KEY_NAME = 'name'; public const KEY_DESCRIPTION = 'description'; public const KEY_PARAMETERS = 'parameters'; + public const KEY_DEFER_LOADING = 'deferLoading'; /** * @var string The name of the function. */ @@ -42,6 +44,11 @@ class FunctionDeclaration extends AbstractDataTransferObject */ private ?array $parameters; + /** + * @var bool Whether loading this function declaration should be deferred until discovered. + */ + private bool $deferLoading; + /** * Constructor. * @@ -50,12 +57,18 @@ class FunctionDeclaration extends AbstractDataTransferObject * @param string $name The name of the function. * @param string $description A description of what the function does. * @param array|null $parameters The JSON schema for the function parameters. + * @param bool $deferLoading Whether loading this function declaration should be deferred until discovered. */ - public function __construct(string $name, string $description, ?array $parameters = null) - { + public function __construct( + string $name, + string $description, + ?array $parameters = null, + bool $deferLoading = false + ) { $this->name = $name; $this->description = $description; $this->parameters = $parameters; + $this->deferLoading = $deferLoading; } /** @@ -94,6 +107,18 @@ public function getParameters(): ?array return $this->parameters; } + /** + * Checks whether loading this function declaration should be deferred until discovered. + * + * @since 1.5.0 + * + * @return bool True if loading should be deferred, false otherwise. + */ + public function isLoadingDeferred(): bool + { + return $this->deferLoading; + } + /** * {@inheritDoc} * @@ -117,6 +142,10 @@ public static function getJsonSchema(): array 'description' => 'The JSON schema for the function parameters.', 'additionalProperties' => true, ], + self::KEY_DEFER_LOADING => [ + 'type' => 'boolean', + 'description' => 'Whether loading this function declaration should be deferred until discovered.', + ], ], 'required' => [self::KEY_NAME, self::KEY_DESCRIPTION], ]; @@ -140,6 +169,10 @@ public function toArray(): array $data[self::KEY_PARAMETERS] = $this->parameters; } + if ($this->deferLoading) { + $data[self::KEY_DEFER_LOADING] = true; + } + return $data; } @@ -155,7 +188,8 @@ public static function fromArray(array $array): self return new self( $array[self::KEY_NAME], $array[self::KEY_DESCRIPTION], - $array[self::KEY_PARAMETERS] ?? null + $array[self::KEY_PARAMETERS] ?? null, + $array[self::KEY_DEFER_LOADING] ?? false ); } } diff --git a/tests/unit/Builders/PromptBuilderTest.php b/tests/unit/Builders/PromptBuilderTest.php index 23a58959..57ac66b2 100644 --- a/tests/unit/Builders/PromptBuilderTest.php +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -3624,6 +3624,28 @@ public function testUsingFunctionDeclarations(): void $this->assertSame($functionDeclaration2, $functionDeclarations[1]); } + /** + * Tests usingToolSearch method. + * + * @return void + */ + public function testUsingToolSearch(): void + { + $builder = new PromptBuilder($this->registry); + + $result = $builder->usingToolSearch(); + + $this->assertSame($builder, $result); + + $reflection = new \ReflectionClass($builder); + $configProperty = $reflection->getProperty('modelConfig'); + $configProperty->setAccessible(true); + /** @var ModelConfig $config */ + $config = $configProperty->getValue($builder); + + $this->assertTrue($config->getToolSearch()); + } + /** * Tests usingPresencePenalty method. * diff --git a/tests/unit/Messages/DTO/MessagePartTest.php b/tests/unit/Messages/DTO/MessagePartTest.php index de7a4ae0..70cb42d3 100644 --- a/tests/unit/Messages/DTO/MessagePartTest.php +++ b/tests/unit/Messages/DTO/MessagePartTest.php @@ -11,6 +11,7 @@ use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Messages\DTO\MessagePart; +use WordPress\AiClient\Messages\DTO\ProviderData; use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Tools\DTO\FunctionCall; @@ -93,6 +94,25 @@ public function testCreateWithFunctionResponseContent(): void $this->assertSame($functionResponse, $part->getFunctionResponse()); } + /** + * Tests creating MessagePart with opaque provider data. + * + * @return void + */ + public function testCreateWithProviderDataContent(): void + { + $providerData = new ProviderData('openai', ['type' => 'tool_search_call', 'id' => 'search_123']); + $part = new MessagePart($providerData); + + $this->assertEquals(MessagePartTypeEnum::providerData(), $part->getType()); + $this->assertEquals(MessagePartChannelEnum::content(), $part->getChannel()); + $this->assertSame($providerData, $part->getProviderData()); + $this->assertNull($part->getText()); + $this->assertNull($part->getFile()); + $this->assertNull($part->getFunctionCall()); + $this->assertNull($part->getFunctionResponse()); + } + /** * Tests creating MessagePart with empty string. * @@ -119,7 +139,7 @@ public function testUnsupportedContentThrowsException($content, string $expected { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage(sprintf( - 'Unsupported content type %s. Expected string, File, FunctionCall, or FunctionResponse.', + 'Unsupported content type %s. Expected string, File, FunctionCall, FunctionResponse, or ProviderData.', $expectedType )); @@ -154,7 +174,7 @@ public function testJsonSchema(): void $this->assertIsArray($schema); $this->assertArrayHasKey('oneOf', $schema); - $this->assertCount(4, $schema['oneOf']); // text, file, function_call, function_response + $this->assertCount(5, $schema['oneOf']); // Check text variant $textSchema = $schema['oneOf'][0]; @@ -198,6 +218,18 @@ public function testJsonSchema(): void [MessagePart::KEY_TYPE, MessagePart::KEY_FUNCTION_RESPONSE], $functionResponseSchema['required'] ); + + // Check provider_data variant + $providerDataSchema = $schema['oneOf'][4]; + $this->assertEquals( + MessagePartTypeEnum::providerData()->value, + $providerDataSchema['properties'][MessagePart::KEY_TYPE]['const'] + ); + $this->assertArrayHasKey(MessagePart::KEY_PROVIDER_DATA, $providerDataSchema['properties']); + $this->assertEquals( + [MessagePart::KEY_TYPE, MessagePart::KEY_PROVIDER_DATA], + $providerDataSchema['required'] + ); } /** @@ -278,6 +310,7 @@ public function testToArrayWithText(): void $this->assertArrayNotHasKey(MessagePart::KEY_FILE, $json); $this->assertArrayNotHasKey(MessagePart::KEY_FUNCTION_CALL, $json); $this->assertArrayNotHasKey(MessagePart::KEY_FUNCTION_RESPONSE, $json); + $this->assertArrayNotHasKey(MessagePart::KEY_PROVIDER_DATA, $json); } /** @@ -377,6 +410,14 @@ public function testArrayRoundTrip(): void $this->assertEquals($functionCall->getName(), $restoredFunc->getFunctionCall()->getName()); $this->assertEquals($functionCall->getArgs(), $restoredFunc->getFunctionCall()->getArgs()); $this->assertEquals($funcPart->getChannel(), $restoredFunc->getChannel()); + + // Test with provider data + $providerData = new ProviderData('anthropic', ['type' => 'tool_reference', 'tool_name' => 'calendar']); + $providerPart = new MessagePart($providerData); + $providerJson = $providerPart->toArray(); + $restoredProvider = MessagePart::fromArray($providerJson); + $this->assertEquals($providerData->getProviderId(), $restoredProvider->getProviderData()->getProviderId()); + $this->assertEquals($providerData->getData(), $restoredProvider->getProviderData()->getData()); } /** @@ -487,6 +528,21 @@ public function testCloneClonesFunctionResponse(): void $this->assertNotSame($original->getFunctionResponse(), $cloned->getFunctionResponse()); } + /** + * Tests that cloning MessagePart with ProviderData creates an independent copy. + * + * @return void + */ + public function testCloneClonesProviderData(): void + { + $providerData = new ProviderData('openai', ['type' => 'tool_search_output']); + $original = new MessagePart($providerData); + $cloned = clone $original; + + $this->assertNotSame($original->getProviderData(), $cloned->getProviderData()); + $this->assertEquals($original->getProviderData(), $cloned->getProviderData()); + } + /** * Tests creating MessagePart with a thought signature. * diff --git a/tests/unit/Messages/DTO/ProviderDataTest.php b/tests/unit/Messages/DTO/ProviderDataTest.php new file mode 100644 index 00000000..9b2ddd26 --- /dev/null +++ b/tests/unit/Messages/DTO/ProviderDataTest.php @@ -0,0 +1,54 @@ + 'tool_search_call', + 'id' => 'search_123', + 'arguments' => ['query' => 'calendar'], + ]; + $providerData = new ProviderData('openai', $data); + + $array = $providerData->toArray(); + $restored = ProviderData::fromArray($array); + + $this->assertEquals('openai', $restored->getProviderId()); + $this->assertEquals($data, $restored->getData()); + $this->assertEquals($array, $restored->toArray()); + } + + /** + * Tests the JSON schema describes provider-scoped opaque data. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = ProviderData::getJsonSchema(); + + $this->assertEquals('object', $schema['type']); + $this->assertEquals( + [ProviderData::KEY_PROVIDER_ID, ProviderData::KEY_DATA], + $schema['required'] + ); + $this->assertTrue($schema['properties'][ProviderData::KEY_DATA]['additionalProperties']); + $this->assertFalse($schema['additionalProperties']); + } +} diff --git a/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php b/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php index 35860816..8e7a6731 100644 --- a/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php +++ b/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php @@ -37,6 +37,7 @@ protected function getExpectedValues(): array 'FILE' => 'file', 'FUNCTION_CALL' => 'function_call', 'FUNCTION_RESPONSE' => 'function_response', + 'PROVIDER_DATA' => 'provider_data', ]; } @@ -58,5 +59,9 @@ public function testSpecificEnumMethods(): void $functionCall = MessagePartTypeEnum::functionCall(); $this->assertTrue($functionCall->isFunctionCall()); $this->assertFalse($functionCall->isFunctionResponse()); + + $providerData = MessagePartTypeEnum::providerData(); + $this->assertTrue($providerData->isProviderData()); + $this->assertFalse($providerData->isText()); } } diff --git a/tests/unit/Providers/Models/DTO/ModelConfigTest.php b/tests/unit/Providers/Models/DTO/ModelConfigTest.php index b9819479..87e93846 100644 --- a/tests/unit/Providers/Models/DTO/ModelConfigTest.php +++ b/tests/unit/Providers/Models/DTO/ModelConfigTest.php @@ -67,6 +67,7 @@ public function testDefaultConstructor(): void $this->assertNull($config->getLogprobs()); $this->assertNull($config->getTopLogprobs()); $this->assertNull($config->getFunctionDeclarations()); + $this->assertNull($config->getToolSearch()); $this->assertNull($config->getWebSearch()); $this->assertNull($config->getOutputFileType()); $this->assertNull($config->getOutputMimeType()); @@ -143,6 +144,10 @@ public function testSettersAndGetters(): void $config->setFunctionDeclarations($functionDeclarations); $this->assertEquals($functionDeclarations, $config->getFunctionDeclarations()); + // Test tool search + $config->setToolSearch(true); + $this->assertTrue($config->getToolSearch()); + // Test web search $webSearch = $this->createSampleWebSearch(); $config->setWebSearch($webSearch); @@ -217,6 +222,7 @@ public function testGetJsonSchema(): void ModelConfig::KEY_LOGPROBS, ModelConfig::KEY_TOP_LOGPROBS, ModelConfig::KEY_FUNCTION_DECLARATIONS, + ModelConfig::KEY_TOOL_SEARCH, ModelConfig::KEY_WEB_SEARCH, ModelConfig::KEY_OUTPUT_FILE_TYPE, ModelConfig::KEY_OUTPUT_MIME_TYPE, @@ -238,6 +244,7 @@ public function testGetJsonSchema(): void $this->assertEquals('integer', $schema['properties'][ModelConfig::KEY_CANDIDATE_COUNT]['type']); $this->assertEquals('number', $schema['properties'][ModelConfig::KEY_TEMPERATURE]['type']); $this->assertEquals('boolean', $schema['properties'][ModelConfig::KEY_LOGPROBS]['type']); + $this->assertEquals('boolean', $schema['properties'][ModelConfig::KEY_TOOL_SEARCH]['type']); $this->assertEquals('string', $schema['properties'][ModelConfig::KEY_OUTPUT_MIME_TYPE]['type']); $this->assertEquals('object', $schema['properties'][ModelConfig::KEY_OUTPUT_SCHEMA]['type']); $this->assertEquals('string', $schema['properties'][ModelConfig::KEY_OUTPUT_FILE_TYPE]['type']); @@ -277,6 +284,7 @@ public function testToArrayAllProperties(): void $config->setLogprobs(true); $config->setTopLogprobs(10); $config->setFunctionDeclarations([$this->createSampleFunctionDeclaration()]); + $config->setToolSearch(true); $config->setWebSearch($this->createSampleWebSearch()); $config->setOutputFileType(FileTypeEnum::remote()); $config->setOutputMimeType('application/json'); @@ -303,6 +311,7 @@ public function testToArrayAllProperties(): void $this->assertTrue($array[ModelConfig::KEY_LOGPROBS]); $this->assertEquals(10, $array[ModelConfig::KEY_TOP_LOGPROBS]); $this->assertCount(1, $array[ModelConfig::KEY_FUNCTION_DECLARATIONS]); + $this->assertTrue($array[ModelConfig::KEY_TOOL_SEARCH]); $this->assertEquals($this->createSampleWebSearch()->toArray(), $array[ModelConfig::KEY_WEB_SEARCH]); $this->assertEquals('remote', $array[ModelConfig::KEY_OUTPUT_FILE_TYPE]); $this->assertEquals('application/json', $array[ModelConfig::KEY_OUTPUT_MIME_TYPE]); @@ -413,6 +422,7 @@ public function testFromArrayAllProperties(): void 'parameters' => ['type' => 'object'] ] ], + ModelConfig::KEY_TOOL_SEARCH => true, ModelConfig::KEY_WEB_SEARCH => [ 'allowedDomains' => ['example.com'], 'disallowedDomains' => ['disallowed.com'], @@ -445,6 +455,7 @@ public function testFromArrayAllProperties(): void $this->assertFalse($config->getLogprobs()); $this->assertEquals(3, $config->getTopLogprobs()); $this->assertCount(1, $config->getFunctionDeclarations()); + $this->assertTrue($config->getToolSearch()); $this->assertInstanceOf(WebSearch::class, $config->getWebSearch()); $this->assertEquals(['example.com'], $config->getWebSearch()->getAllowedDomains()); $this->assertEquals(['disallowed.com'], $config->getWebSearch()->getDisallowedDomains()); diff --git a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php index ddc8f157..050d07b7 100644 --- a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php +++ b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php @@ -19,6 +19,7 @@ use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\Enums\OptionEnum; +use WordPress\AiClient\Tools\DTO\FunctionDeclaration; /** * @covers \WordPress\AiClient\Providers\Models\DTO\ModelRequirements @@ -639,4 +640,58 @@ public function testFromPromptDataWithModelConfigOptions(): void $this->assertTrue($hasTopP, 'Top P option should be present'); $this->assertTrue($hasDimensions, 'Dimensions option should be present'); } + + /** + * Tests deferred function declarations require native tool search support. + * + * @return void + */ + public function testFromPromptDataWithDeferredFunctionDeclaration(): void + { + $messages = [new UserMessage([new MessagePart('Find the right tool')])]; + $modelConfig = new ModelConfig(); + $modelConfig->setFunctionDeclarations([ + new FunctionDeclaration('search_catalog', 'Search the catalog', null, true), + ]); + + $requirements = ModelRequirements::fromPromptData( + CapabilityEnum::textGeneration(), + $messages, + $modelConfig + ); + + $toolSearchOptions = array_filter( + $requirements->getRequiredOptions(), + static fn(RequiredOption $option): bool => $option->getName()->isToolSearch() + ); + + $this->assertCount(1, $toolSearchOptions); + $this->assertTrue(array_values($toolSearchOptions)[0]->getValue()); + } + + /** + * Tests explicitly enabling tool search requires native support. + * + * @return void + */ + public function testFromPromptDataWithExplicitToolSearch(): void + { + $messages = [new UserMessage([new MessagePart('Find the right tool')])]; + $modelConfig = new ModelConfig(); + $modelConfig->setToolSearch(true); + + $requirements = ModelRequirements::fromPromptData( + CapabilityEnum::textGeneration(), + $messages, + $modelConfig + ); + + $toolSearchOptions = array_filter( + $requirements->getRequiredOptions(), + static fn(RequiredOption $option): bool => $option->getName()->isToolSearch() + ); + + $this->assertCount(1, $toolSearchOptions); + $this->assertTrue(array_values($toolSearchOptions)[0]->getValue()); + } } diff --git a/tests/unit/Providers/Models/Enums/OptionEnumTest.php b/tests/unit/Providers/Models/Enums/OptionEnumTest.php index 4b194ba0..ebb19c28 100644 --- a/tests/unit/Providers/Models/Enums/OptionEnumTest.php +++ b/tests/unit/Providers/Models/Enums/OptionEnumTest.php @@ -50,6 +50,7 @@ protected function getExpectedValues(): array 'LOGPROBS' => 'logprobs', 'TOP_LOGPROBS' => 'topLogprobs', 'FUNCTION_DECLARATIONS' => 'functionDeclarations', + 'TOOL_SEARCH' => 'toolSearch', 'WEB_SEARCH' => 'webSearch', 'OUTPUT_FILE_TYPE' => 'outputFileType', 'OUTPUT_MIME_TYPE' => 'outputMimeType', @@ -108,6 +109,7 @@ public function testDynamicallyLoadedConstants(): void $this->assertInstanceOf(OptionEnum::class, OptionEnum::logprobs()); $this->assertInstanceOf(OptionEnum::class, OptionEnum::topLogprobs()); $this->assertInstanceOf(OptionEnum::class, OptionEnum::functionDeclarations()); + $this->assertInstanceOf(OptionEnum::class, OptionEnum::toolSearch()); $this->assertInstanceOf(OptionEnum::class, OptionEnum::webSearch()); $this->assertInstanceOf(OptionEnum::class, OptionEnum::outputFileType()); $this->assertInstanceOf(OptionEnum::class, OptionEnum::outputMediaOrientation()); @@ -132,6 +134,7 @@ public function testGetValuesIncludesDynamicConstants(): void $this->assertContains('logprobs', $values); $this->assertContains('topLogprobs', $values); $this->assertContains('functionDeclarations', $values); + $this->assertContains('toolSearch', $values); $this->assertContains('webSearch', $values); $this->assertContains('outputFileType', $values); $this->assertContains('outputMediaOrientation', $values); diff --git a/tests/unit/Tools/DTO/FunctionDeclarationTest.php b/tests/unit/Tools/DTO/FunctionDeclarationTest.php index b2782bdb..b211a2d1 100644 --- a/tests/unit/Tools/DTO/FunctionDeclarationTest.php +++ b/tests/unit/Tools/DTO/FunctionDeclarationTest.php @@ -38,6 +38,7 @@ public function testCreateWithAllProperties(): void $this->assertEquals($name, $declaration->getName()); $this->assertEquals($description, $declaration->getDescription()); $this->assertEquals($parameters, $declaration->getParameters()); + $this->assertFalse($declaration->isLoadingDeferred()); } /** @@ -112,6 +113,7 @@ public function testJsonSchema(): void $this->assertArrayHasKey(FunctionDeclaration::KEY_NAME, $schema['properties']); $this->assertArrayHasKey(FunctionDeclaration::KEY_DESCRIPTION, $schema['properties']); $this->assertArrayHasKey(FunctionDeclaration::KEY_PARAMETERS, $schema['properties']); + $this->assertArrayHasKey(FunctionDeclaration::KEY_DEFER_LOADING, $schema['properties']); // Check name property $this->assertEquals('string', $schema['properties'][FunctionDeclaration::KEY_NAME]['type']); @@ -125,6 +127,7 @@ public function testJsonSchema(): void // Parameters should be object type (for JSON schema) $this->assertEquals('object', $schema['properties'][FunctionDeclaration::KEY_PARAMETERS]['type']); $this->assertTrue($schema['properties'][FunctionDeclaration::KEY_PARAMETERS]['additionalProperties']); + $this->assertEquals('boolean', $schema['properties'][FunctionDeclaration::KEY_DEFER_LOADING]['type']); // Check required fields - parameters should NOT be required $this->assertArrayHasKey('required', $schema); @@ -222,10 +225,27 @@ public function testToArrayWithoutParameters(): void $this->assertArrayHasKeys($json, [FunctionDeclaration::KEY_NAME, FunctionDeclaration::KEY_DESCRIPTION]); $this->assertArrayNotHasKey(FunctionDeclaration::KEY_PARAMETERS, $json); + $this->assertArrayNotHasKey(FunctionDeclaration::KEY_DEFER_LOADING, $json); $this->assertEquals('getTimestamp', $json[FunctionDeclaration::KEY_NAME]); $this->assertEquals('Returns the current Unix timestamp', $json[FunctionDeclaration::KEY_DESCRIPTION]); } + /** + * Tests deferred loading is opt-in and survives array transformation. + * + * @return void + */ + public function testDeferredLoadingRoundTrip(): void + { + $declaration = new FunctionDeclaration('search', 'Search the catalog', null, true); + + $array = $declaration->toArray(); + $restored = FunctionDeclaration::fromArray($array); + + $this->assertTrue($array[FunctionDeclaration::KEY_DEFER_LOADING]); + $this->assertTrue($restored->isLoadingDeferred()); + } + /** * Tests fromJson method with parameters. * @@ -306,6 +326,7 @@ function ($original, $restored) { $this->assertEquals($original->getName(), $restored->getName()); $this->assertEquals($original->getDescription(), $restored->getDescription()); $this->assertEquals($original->getParameters(), $restored->getParameters()); + $this->assertEquals($original->isLoadingDeferred(), $restored->isLoadingDeferred()); } ); } From ac965d221d453a0fb30461a919206dd431305086 Mon Sep 17 00:00:00 2001 From: David Stone Date: Tue, 25 Aug 2026 23:08:42 -0600 Subject: [PATCH 2/5] Add coverage for invalid message parts --- tests/unit/Messages/DTO/MessagePartTest.php | 39 +++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/unit/Messages/DTO/MessagePartTest.php b/tests/unit/Messages/DTO/MessagePartTest.php index 70cb42d3..7cd39bdf 100644 --- a/tests/unit/Messages/DTO/MessagePartTest.php +++ b/tests/unit/Messages/DTO/MessagePartTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use stdClass; use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; +use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Messages\DTO\MessagePart; @@ -163,6 +164,44 @@ public function unsupportedContentProvider(): array ]; } + /** + * Tests toArray rejects an internally invalid part without content. + * + * @return void + */ + public function testToArrayWithoutContentThrowsException(): void + { + $part = new MessagePart('content'); + $textProperty = new \ReflectionProperty(MessagePart::class, 'text'); + $textProperty->setAccessible(true); + $textProperty->setValue($part, null); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'MessagePart requires one of: text, file, functionCall, functionResponse, or providerData.' + ); + + $part->toArray(); + } + + /** + * Tests fromArray rejects data without supported content. + * + * @return void + */ + public function testFromArrayWithoutContentThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'MessagePart requires one of: text, file, functionCall, functionResponse, or providerData.' + ); + + MessagePart::fromArray([ + MessagePart::KEY_CHANNEL => MessagePartChannelEnum::content()->value, + MessagePart::KEY_TYPE => MessagePartTypeEnum::providerData()->value, + ]); + } + /** * Tests JSON schema. * From 55a42d9ca2c3968df9c442d36574702783ff430c Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 9 Sep 2026 12:36:40 -0600 Subject: [PATCH 3/5] Keep tool search policy in providers and limit core to conversation data --- docs/PROVIDER_DATA.md | 52 ++++++++++++++++++ src/Builders/PromptBuilder.php | 13 ----- src/Messages/DTO/MessagePart.php | 4 +- src/Messages/DTO/ProviderData.php | 14 ++--- src/Providers/Models/DTO/ModelConfig.php | 43 --------------- .../Models/DTO/ModelRequirements.php | 12 ---- src/Providers/Models/Enums/OptionEnum.php | 2 - src/Tools/DTO/FunctionDeclaration.php | 42 ++------------ tests/unit/Builders/PromptBuilderTest.php | 22 -------- .../Providers/Models/DTO/ModelConfigTest.php | 11 ---- .../Models/DTO/ModelRequirementsTest.php | 55 ------------------- .../Providers/Models/Enums/OptionEnumTest.php | 3 - .../Tools/DTO/FunctionDeclarationTest.php | 21 ------- 13 files changed, 65 insertions(+), 229 deletions(-) create mode 100644 docs/PROVIDER_DATA.md diff --git a/docs/PROVIDER_DATA.md b/docs/PROVIDER_DATA.md new file mode 100644 index 00000000..22ad92d0 --- /dev/null +++ b/docs/PROVIDER_DATA.md @@ -0,0 +1,52 @@ +# Provider-native conversation data + +`ProviderData` lets a provider retain a native conversation item as an ordered +`MessagePart` without adding that provider's wire format to the SDK. It is opaque +to the core client. Array/JSON serialization and cloning preserve it alongside +ordinary text and function-call parts. + +Providers must check the originating provider ID and validate the payload before +replaying it. A provider ID is an ownership label, not authentication: persisted +messages and tool execution still need application-level authorization. Providers +that do not recognize this part type must not interpret it as text or execute it +as an application function call. + +Applications should preserve all returned message parts when saving history. +Converting a result to text loses non-text conversation state. + +## Why this small core extension? + +The motivating consumer is hosted tool search in the OpenAI provider. The provider +can choose an automatic tool-count threshold, emit `defer_loading` and +`tool_search`, and consume policy overrides through the existing +`ModelConfig::setCustomOption()` API. None of that needs a core tool-search option, +a `FunctionDeclaration` flag, or a `PromptBuilder::usingToolSearch()` method. + +Request parameters alone do not preserve the search call and loaded-tool output +across stateless, serialized message history. Result-level provider metadata is +not retained by message conversion; thought signatures describe reasoning, not +tool discovery. `ProviderData` addresses only that conversation-state gap. + +Using `previous_response_id` through custom options is a zero-core-change +alternative when the application deliberately uses OpenAI-managed conversation +state. It requires the application to retain response IDs and send only new input, +and is not a substitute for portable, stateless message history. + +## Research (2026-09-09) + +- [OpenAI tool search](https://developers.openai.com/api/docs/guides/tools-tool-search) + supports hosted discovery on GPT-5.4 and later compatible Responses models. + Deferred flat functions retain their names/descriptions in context and defer + mainly parameter schemas. Namespaces can yield larger savings, but should not + be invented automatically without meaningful grouping metadata. +- [Vercel AI SDK v7 OpenAI provider](https://ai-sdk.dev/providers/ai-sdk-providers/openai) + uses an explicit provider tool plus per-function `providerOptions.openai.deferLoading`. + It preserves provider-specific replay metadata. It does not document an automatic + tool-count activation threshold. +- [Vercel Anthropic provider](https://ai-sdk.dev/providers/ai-sdk-providers/anthropic) + likewise exposes provider-defined search tools and deferred-loading options. + +An automatic threshold such as more than 10 functions is a provider experiment, +not a cross-provider capability guarantee or a demonstrated performance win. +Measure task success, total tokens, and end-to-end latency against eager loading +before promoting that policy or further tool-search APIs into core. diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 2033725c..538392db 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -331,19 +331,6 @@ public function usingFunctionDeclarations(FunctionDeclaration ...$functionDeclar return $this; } - /** - * Enables native tool search for deferred function declarations. - * - * @since 1.5.0 - * - * @return self - */ - public function usingToolSearch(): self - { - $this->modelConfig->setToolSearch(true); - return $this; - } - /** * Sets the presence penalty for generation. * diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index 358e3e82..d90ee3e5 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -219,7 +219,7 @@ public function getFunctionResponse(): ?FunctionResponse /** * Gets the opaque provider-native data. * - * @since 1.5.0 + * @since n.e.x.t * * @return ProviderData|null The provider-native data or null if not a provider data part. */ @@ -403,7 +403,7 @@ public static function fromArray(array $array): self /** * Performs a deep clone of the message part. * - * This method ensures that nested objects (file, function call, function response) + * This method ensures that nested objects (file, function call, function response, provider data) * are cloned to prevent modifications to the cloned part from affecting the original. * * @since 0.4.2 diff --git a/src/Messages/DTO/ProviderData.php b/src/Messages/DTO/ProviderData.php index 3d5e871e..7baa88a5 100644 --- a/src/Messages/DTO/ProviderData.php +++ b/src/Messages/DTO/ProviderData.php @@ -13,7 +13,7 @@ * replay them in their original order without exposing provider wire formats as * provider-agnostic message part types. * - * @since 1.5.0 + * @since n.e.x.t * * @phpstan-type ProviderDataArrayShape array{ * providerId: string, @@ -40,7 +40,7 @@ class ProviderData extends AbstractDataTransferObject /** * Constructor. * - * @since 1.5.0 + * @since n.e.x.t * * @param string $providerId The identifier of the provider that owns this data. * @param array $data The opaque provider-native data. @@ -54,7 +54,7 @@ public function __construct(string $providerId, array $data) /** * Gets the identifier of the provider that owns this data. * - * @since 1.5.0 + * @since n.e.x.t * * @return string The provider identifier. */ @@ -66,7 +66,7 @@ public function getProviderId(): string /** * Gets the opaque provider-native data. * - * @since 1.5.0 + * @since n.e.x.t * * @return array The provider-native data. */ @@ -78,7 +78,7 @@ public function getData(): array /** * {@inheritDoc} * - * @since 1.5.0 + * @since n.e.x.t */ public static function getJsonSchema(): array { @@ -103,7 +103,7 @@ public static function getJsonSchema(): array /** * {@inheritDoc} * - * @since 1.5.0 + * @since n.e.x.t * * @return ProviderDataArrayShape */ @@ -118,7 +118,7 @@ public function toArray(): array /** * {@inheritDoc} * - * @since 1.5.0 + * @since n.e.x.t */ public static function fromArray(array $array): self { diff --git a/src/Providers/Models/DTO/ModelConfig.php b/src/Providers/Models/DTO/ModelConfig.php index beb97559..fad995d0 100644 --- a/src/Providers/Models/DTO/ModelConfig.php +++ b/src/Providers/Models/DTO/ModelConfig.php @@ -38,7 +38,6 @@ * logprobs?: bool, * topLogprobs?: int, * functionDeclarations?: list, - * toolSearch?: bool, * webSearch?: WebSearchArrayShape, * outputFileType?: string, * outputMimeType?: string, @@ -67,7 +66,6 @@ class ModelConfig extends AbstractDataTransferObject public const KEY_LOGPROBS = 'logprobs'; public const KEY_TOP_LOGPROBS = 'topLogprobs'; public const KEY_FUNCTION_DECLARATIONS = 'functionDeclarations'; - public const KEY_TOOL_SEARCH = 'toolSearch'; public const KEY_WEB_SEARCH = 'webSearch'; public const KEY_OUTPUT_FILE_TYPE = 'outputFileType'; public const KEY_OUTPUT_MIME_TYPE = 'outputMimeType'; @@ -150,11 +148,6 @@ class ModelConfig extends AbstractDataTransferObject */ protected ?array $functionDeclarations = null; - /** - * @var bool|null Whether native tool search should be enabled for deferred function declarations. - */ - protected ?bool $toolSearch = null; - /** * @var WebSearch|null Web search configuration for the model. */ @@ -560,30 +553,6 @@ public function getFunctionDeclarations(): ?array return $this->functionDeclarations; } - /** - * Sets whether native tool search should be enabled for deferred function declarations. - * - * @since 1.5.0 - * - * @param bool $toolSearch Whether native tool search should be enabled. - */ - public function setToolSearch(bool $toolSearch): void - { - $this->toolSearch = $toolSearch; - } - - /** - * Gets whether native tool search should be enabled for deferred function declarations. - * - * @since 1.5.0 - * - * @return bool|null Whether native tool search should be enabled, or null if not configured. - */ - public function getToolSearch(): ?bool - { - return $this->toolSearch; - } - /** * Sets the web search configuration. * @@ -952,10 +921,6 @@ public static function getJsonSchema(): array 'items' => FunctionDeclaration::getJsonSchema(), 'description' => 'Function declarations available to the model.', ], - self::KEY_TOOL_SEARCH => [ - 'type' => 'boolean', - 'description' => 'Whether native tool search is enabled for deferred function declarations.', - ], self::KEY_WEB_SEARCH => WebSearch::getJsonSchema(), self::KEY_OUTPUT_FILE_TYPE => [ 'type' => 'string', @@ -1073,10 +1038,6 @@ static function (FunctionDeclaration $functionDeclaration): array { ); } - if ($this->toolSearch !== null) { - $data[self::KEY_TOOL_SEARCH] = $this->toolSearch; - } - if ($this->webSearch !== null) { $data[self::KEY_WEB_SEARCH] = $this->webSearch->toArray(); } @@ -1186,10 +1147,6 @@ static function (array $functionDeclarationData): FunctionDeclaration { )); } - if (isset($array[self::KEY_TOOL_SEARCH])) { - $config->setToolSearch($array[self::KEY_TOOL_SEARCH]); - } - if (isset($array[self::KEY_WEB_SEARCH])) { $config->setWebSearch(WebSearch::fromArray($array[self::KEY_WEB_SEARCH])); } diff --git a/src/Providers/Models/DTO/ModelRequirements.php b/src/Providers/Models/DTO/ModelRequirements.php index 773127ff..492e5fd8 100644 --- a/src/Providers/Models/DTO/ModelRequirements.php +++ b/src/Providers/Models/DTO/ModelRequirements.php @@ -382,18 +382,6 @@ private static function toRequiredOptions(ModelConfig $modelConfig): array $requiredOptions[] = new RequiredOption(OptionEnum::functionDeclarations(), true); } - $requiresToolSearch = $modelConfig->getToolSearch() === true; - foreach ($modelConfig->getFunctionDeclarations() ?? [] as $functionDeclaration) { - if ($functionDeclaration->isLoadingDeferred()) { - $requiresToolSearch = true; - break; - } - } - - if ($requiresToolSearch) { - $requiredOptions[] = new RequiredOption(OptionEnum::toolSearch(), true); - } - if ($modelConfig->getWebSearch() !== null) { $requiredOptions[] = new RequiredOption(OptionEnum::webSearch(), true); } diff --git a/src/Providers/Models/Enums/OptionEnum.php b/src/Providers/Models/Enums/OptionEnum.php index 40cd82ce..dde22ae2 100644 --- a/src/Providers/Models/Enums/OptionEnum.php +++ b/src/Providers/Models/Enums/OptionEnum.php @@ -37,7 +37,6 @@ * @method static self stopSequences() Creates an instance for STOP_SEQUENCES option. * @method static self systemInstruction() Creates an instance for SYSTEM_INSTRUCTION option. * @method static self temperature() Creates an instance for TEMPERATURE option. - * @method static self toolSearch() Creates an instance for TOOL_SEARCH option. * @method static self topK() Creates an instance for TOP_K option. * @method static self topLogprobs() Creates an instance for TOP_LOGPROBS option. * @method static self topP() Creates an instance for TOP_P option. @@ -60,7 +59,6 @@ * @method bool isStopSequences() Checks if the option is STOP_SEQUENCES. * @method bool isSystemInstruction() Checks if the option is SYSTEM_INSTRUCTION. * @method bool isTemperature() Checks if the option is TEMPERATURE. - * @method bool isToolSearch() Checks if the option is TOOL_SEARCH. * @method bool isTopK() Checks if the option is TOP_K. * @method bool isTopLogprobs() Checks if the option is TOP_LOGPROBS. * @method bool isTopP() Checks if the option is TOP_P. diff --git a/src/Tools/DTO/FunctionDeclaration.php b/src/Tools/DTO/FunctionDeclaration.php index 545ec034..5b08ce80 100644 --- a/src/Tools/DTO/FunctionDeclaration.php +++ b/src/Tools/DTO/FunctionDeclaration.php @@ -17,8 +17,7 @@ * @phpstan-type FunctionDeclarationArrayShape array{ * name: string, * description: string, - * parameters?: array, - * deferLoading?: bool + * parameters?: array * } * * @extends AbstractDataTransferObject @@ -28,7 +27,6 @@ class FunctionDeclaration extends AbstractDataTransferObject public const KEY_NAME = 'name'; public const KEY_DESCRIPTION = 'description'; public const KEY_PARAMETERS = 'parameters'; - public const KEY_DEFER_LOADING = 'deferLoading'; /** * @var string The name of the function. */ @@ -44,11 +42,6 @@ class FunctionDeclaration extends AbstractDataTransferObject */ private ?array $parameters; - /** - * @var bool Whether loading this function declaration should be deferred until discovered. - */ - private bool $deferLoading; - /** * Constructor. * @@ -57,18 +50,12 @@ class FunctionDeclaration extends AbstractDataTransferObject * @param string $name The name of the function. * @param string $description A description of what the function does. * @param array|null $parameters The JSON schema for the function parameters. - * @param bool $deferLoading Whether loading this function declaration should be deferred until discovered. */ - public function __construct( - string $name, - string $description, - ?array $parameters = null, - bool $deferLoading = false - ) { + public function __construct(string $name, string $description, ?array $parameters = null) + { $this->name = $name; $this->description = $description; $this->parameters = $parameters; - $this->deferLoading = $deferLoading; } /** @@ -107,18 +94,6 @@ public function getParameters(): ?array return $this->parameters; } - /** - * Checks whether loading this function declaration should be deferred until discovered. - * - * @since 1.5.0 - * - * @return bool True if loading should be deferred, false otherwise. - */ - public function isLoadingDeferred(): bool - { - return $this->deferLoading; - } - /** * {@inheritDoc} * @@ -142,10 +117,6 @@ public static function getJsonSchema(): array 'description' => 'The JSON schema for the function parameters.', 'additionalProperties' => true, ], - self::KEY_DEFER_LOADING => [ - 'type' => 'boolean', - 'description' => 'Whether loading this function declaration should be deferred until discovered.', - ], ], 'required' => [self::KEY_NAME, self::KEY_DESCRIPTION], ]; @@ -169,10 +140,6 @@ public function toArray(): array $data[self::KEY_PARAMETERS] = $this->parameters; } - if ($this->deferLoading) { - $data[self::KEY_DEFER_LOADING] = true; - } - return $data; } @@ -188,8 +155,7 @@ public static function fromArray(array $array): self return new self( $array[self::KEY_NAME], $array[self::KEY_DESCRIPTION], - $array[self::KEY_PARAMETERS] ?? null, - $array[self::KEY_DEFER_LOADING] ?? false + $array[self::KEY_PARAMETERS] ?? null ); } } diff --git a/tests/unit/Builders/PromptBuilderTest.php b/tests/unit/Builders/PromptBuilderTest.php index 57ac66b2..23a58959 100644 --- a/tests/unit/Builders/PromptBuilderTest.php +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -3624,28 +3624,6 @@ public function testUsingFunctionDeclarations(): void $this->assertSame($functionDeclaration2, $functionDeclarations[1]); } - /** - * Tests usingToolSearch method. - * - * @return void - */ - public function testUsingToolSearch(): void - { - $builder = new PromptBuilder($this->registry); - - $result = $builder->usingToolSearch(); - - $this->assertSame($builder, $result); - - $reflection = new \ReflectionClass($builder); - $configProperty = $reflection->getProperty('modelConfig'); - $configProperty->setAccessible(true); - /** @var ModelConfig $config */ - $config = $configProperty->getValue($builder); - - $this->assertTrue($config->getToolSearch()); - } - /** * Tests usingPresencePenalty method. * diff --git a/tests/unit/Providers/Models/DTO/ModelConfigTest.php b/tests/unit/Providers/Models/DTO/ModelConfigTest.php index 87e93846..b9819479 100644 --- a/tests/unit/Providers/Models/DTO/ModelConfigTest.php +++ b/tests/unit/Providers/Models/DTO/ModelConfigTest.php @@ -67,7 +67,6 @@ public function testDefaultConstructor(): void $this->assertNull($config->getLogprobs()); $this->assertNull($config->getTopLogprobs()); $this->assertNull($config->getFunctionDeclarations()); - $this->assertNull($config->getToolSearch()); $this->assertNull($config->getWebSearch()); $this->assertNull($config->getOutputFileType()); $this->assertNull($config->getOutputMimeType()); @@ -144,10 +143,6 @@ public function testSettersAndGetters(): void $config->setFunctionDeclarations($functionDeclarations); $this->assertEquals($functionDeclarations, $config->getFunctionDeclarations()); - // Test tool search - $config->setToolSearch(true); - $this->assertTrue($config->getToolSearch()); - // Test web search $webSearch = $this->createSampleWebSearch(); $config->setWebSearch($webSearch); @@ -222,7 +217,6 @@ public function testGetJsonSchema(): void ModelConfig::KEY_LOGPROBS, ModelConfig::KEY_TOP_LOGPROBS, ModelConfig::KEY_FUNCTION_DECLARATIONS, - ModelConfig::KEY_TOOL_SEARCH, ModelConfig::KEY_WEB_SEARCH, ModelConfig::KEY_OUTPUT_FILE_TYPE, ModelConfig::KEY_OUTPUT_MIME_TYPE, @@ -244,7 +238,6 @@ public function testGetJsonSchema(): void $this->assertEquals('integer', $schema['properties'][ModelConfig::KEY_CANDIDATE_COUNT]['type']); $this->assertEquals('number', $schema['properties'][ModelConfig::KEY_TEMPERATURE]['type']); $this->assertEquals('boolean', $schema['properties'][ModelConfig::KEY_LOGPROBS]['type']); - $this->assertEquals('boolean', $schema['properties'][ModelConfig::KEY_TOOL_SEARCH]['type']); $this->assertEquals('string', $schema['properties'][ModelConfig::KEY_OUTPUT_MIME_TYPE]['type']); $this->assertEquals('object', $schema['properties'][ModelConfig::KEY_OUTPUT_SCHEMA]['type']); $this->assertEquals('string', $schema['properties'][ModelConfig::KEY_OUTPUT_FILE_TYPE]['type']); @@ -284,7 +277,6 @@ public function testToArrayAllProperties(): void $config->setLogprobs(true); $config->setTopLogprobs(10); $config->setFunctionDeclarations([$this->createSampleFunctionDeclaration()]); - $config->setToolSearch(true); $config->setWebSearch($this->createSampleWebSearch()); $config->setOutputFileType(FileTypeEnum::remote()); $config->setOutputMimeType('application/json'); @@ -311,7 +303,6 @@ public function testToArrayAllProperties(): void $this->assertTrue($array[ModelConfig::KEY_LOGPROBS]); $this->assertEquals(10, $array[ModelConfig::KEY_TOP_LOGPROBS]); $this->assertCount(1, $array[ModelConfig::KEY_FUNCTION_DECLARATIONS]); - $this->assertTrue($array[ModelConfig::KEY_TOOL_SEARCH]); $this->assertEquals($this->createSampleWebSearch()->toArray(), $array[ModelConfig::KEY_WEB_SEARCH]); $this->assertEquals('remote', $array[ModelConfig::KEY_OUTPUT_FILE_TYPE]); $this->assertEquals('application/json', $array[ModelConfig::KEY_OUTPUT_MIME_TYPE]); @@ -422,7 +413,6 @@ public function testFromArrayAllProperties(): void 'parameters' => ['type' => 'object'] ] ], - ModelConfig::KEY_TOOL_SEARCH => true, ModelConfig::KEY_WEB_SEARCH => [ 'allowedDomains' => ['example.com'], 'disallowedDomains' => ['disallowed.com'], @@ -455,7 +445,6 @@ public function testFromArrayAllProperties(): void $this->assertFalse($config->getLogprobs()); $this->assertEquals(3, $config->getTopLogprobs()); $this->assertCount(1, $config->getFunctionDeclarations()); - $this->assertTrue($config->getToolSearch()); $this->assertInstanceOf(WebSearch::class, $config->getWebSearch()); $this->assertEquals(['example.com'], $config->getWebSearch()->getAllowedDomains()); $this->assertEquals(['disallowed.com'], $config->getWebSearch()->getDisallowedDomains()); diff --git a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php index 050d07b7..ddc8f157 100644 --- a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php +++ b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php @@ -19,7 +19,6 @@ use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\Enums\OptionEnum; -use WordPress\AiClient\Tools\DTO\FunctionDeclaration; /** * @covers \WordPress\AiClient\Providers\Models\DTO\ModelRequirements @@ -640,58 +639,4 @@ public function testFromPromptDataWithModelConfigOptions(): void $this->assertTrue($hasTopP, 'Top P option should be present'); $this->assertTrue($hasDimensions, 'Dimensions option should be present'); } - - /** - * Tests deferred function declarations require native tool search support. - * - * @return void - */ - public function testFromPromptDataWithDeferredFunctionDeclaration(): void - { - $messages = [new UserMessage([new MessagePart('Find the right tool')])]; - $modelConfig = new ModelConfig(); - $modelConfig->setFunctionDeclarations([ - new FunctionDeclaration('search_catalog', 'Search the catalog', null, true), - ]); - - $requirements = ModelRequirements::fromPromptData( - CapabilityEnum::textGeneration(), - $messages, - $modelConfig - ); - - $toolSearchOptions = array_filter( - $requirements->getRequiredOptions(), - static fn(RequiredOption $option): bool => $option->getName()->isToolSearch() - ); - - $this->assertCount(1, $toolSearchOptions); - $this->assertTrue(array_values($toolSearchOptions)[0]->getValue()); - } - - /** - * Tests explicitly enabling tool search requires native support. - * - * @return void - */ - public function testFromPromptDataWithExplicitToolSearch(): void - { - $messages = [new UserMessage([new MessagePart('Find the right tool')])]; - $modelConfig = new ModelConfig(); - $modelConfig->setToolSearch(true); - - $requirements = ModelRequirements::fromPromptData( - CapabilityEnum::textGeneration(), - $messages, - $modelConfig - ); - - $toolSearchOptions = array_filter( - $requirements->getRequiredOptions(), - static fn(RequiredOption $option): bool => $option->getName()->isToolSearch() - ); - - $this->assertCount(1, $toolSearchOptions); - $this->assertTrue(array_values($toolSearchOptions)[0]->getValue()); - } } diff --git a/tests/unit/Providers/Models/Enums/OptionEnumTest.php b/tests/unit/Providers/Models/Enums/OptionEnumTest.php index ebb19c28..4b194ba0 100644 --- a/tests/unit/Providers/Models/Enums/OptionEnumTest.php +++ b/tests/unit/Providers/Models/Enums/OptionEnumTest.php @@ -50,7 +50,6 @@ protected function getExpectedValues(): array 'LOGPROBS' => 'logprobs', 'TOP_LOGPROBS' => 'topLogprobs', 'FUNCTION_DECLARATIONS' => 'functionDeclarations', - 'TOOL_SEARCH' => 'toolSearch', 'WEB_SEARCH' => 'webSearch', 'OUTPUT_FILE_TYPE' => 'outputFileType', 'OUTPUT_MIME_TYPE' => 'outputMimeType', @@ -109,7 +108,6 @@ public function testDynamicallyLoadedConstants(): void $this->assertInstanceOf(OptionEnum::class, OptionEnum::logprobs()); $this->assertInstanceOf(OptionEnum::class, OptionEnum::topLogprobs()); $this->assertInstanceOf(OptionEnum::class, OptionEnum::functionDeclarations()); - $this->assertInstanceOf(OptionEnum::class, OptionEnum::toolSearch()); $this->assertInstanceOf(OptionEnum::class, OptionEnum::webSearch()); $this->assertInstanceOf(OptionEnum::class, OptionEnum::outputFileType()); $this->assertInstanceOf(OptionEnum::class, OptionEnum::outputMediaOrientation()); @@ -134,7 +132,6 @@ public function testGetValuesIncludesDynamicConstants(): void $this->assertContains('logprobs', $values); $this->assertContains('topLogprobs', $values); $this->assertContains('functionDeclarations', $values); - $this->assertContains('toolSearch', $values); $this->assertContains('webSearch', $values); $this->assertContains('outputFileType', $values); $this->assertContains('outputMediaOrientation', $values); diff --git a/tests/unit/Tools/DTO/FunctionDeclarationTest.php b/tests/unit/Tools/DTO/FunctionDeclarationTest.php index b211a2d1..b2782bdb 100644 --- a/tests/unit/Tools/DTO/FunctionDeclarationTest.php +++ b/tests/unit/Tools/DTO/FunctionDeclarationTest.php @@ -38,7 +38,6 @@ public function testCreateWithAllProperties(): void $this->assertEquals($name, $declaration->getName()); $this->assertEquals($description, $declaration->getDescription()); $this->assertEquals($parameters, $declaration->getParameters()); - $this->assertFalse($declaration->isLoadingDeferred()); } /** @@ -113,7 +112,6 @@ public function testJsonSchema(): void $this->assertArrayHasKey(FunctionDeclaration::KEY_NAME, $schema['properties']); $this->assertArrayHasKey(FunctionDeclaration::KEY_DESCRIPTION, $schema['properties']); $this->assertArrayHasKey(FunctionDeclaration::KEY_PARAMETERS, $schema['properties']); - $this->assertArrayHasKey(FunctionDeclaration::KEY_DEFER_LOADING, $schema['properties']); // Check name property $this->assertEquals('string', $schema['properties'][FunctionDeclaration::KEY_NAME]['type']); @@ -127,7 +125,6 @@ public function testJsonSchema(): void // Parameters should be object type (for JSON schema) $this->assertEquals('object', $schema['properties'][FunctionDeclaration::KEY_PARAMETERS]['type']); $this->assertTrue($schema['properties'][FunctionDeclaration::KEY_PARAMETERS]['additionalProperties']); - $this->assertEquals('boolean', $schema['properties'][FunctionDeclaration::KEY_DEFER_LOADING]['type']); // Check required fields - parameters should NOT be required $this->assertArrayHasKey('required', $schema); @@ -225,27 +222,10 @@ public function testToArrayWithoutParameters(): void $this->assertArrayHasKeys($json, [FunctionDeclaration::KEY_NAME, FunctionDeclaration::KEY_DESCRIPTION]); $this->assertArrayNotHasKey(FunctionDeclaration::KEY_PARAMETERS, $json); - $this->assertArrayNotHasKey(FunctionDeclaration::KEY_DEFER_LOADING, $json); $this->assertEquals('getTimestamp', $json[FunctionDeclaration::KEY_NAME]); $this->assertEquals('Returns the current Unix timestamp', $json[FunctionDeclaration::KEY_DESCRIPTION]); } - /** - * Tests deferred loading is opt-in and survives array transformation. - * - * @return void - */ - public function testDeferredLoadingRoundTrip(): void - { - $declaration = new FunctionDeclaration('search', 'Search the catalog', null, true); - - $array = $declaration->toArray(); - $restored = FunctionDeclaration::fromArray($array); - - $this->assertTrue($array[FunctionDeclaration::KEY_DEFER_LOADING]); - $this->assertTrue($restored->isLoadingDeferred()); - } - /** * Tests fromJson method with parameters. * @@ -326,7 +306,6 @@ function ($original, $restored) { $this->assertEquals($original->getName(), $restored->getName()); $this->assertEquals($original->getDescription(), $restored->getDescription()); $this->assertEquals($original->getParameters(), $restored->getParameters()); - $this->assertEquals($original->isLoadingDeferred(), $restored->isLoadingDeferred()); } ); } From dec7664cc17d8cf4287680c564fcdc428ebce7ec Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 9 Sep 2026 13:05:01 -0600 Subject: [PATCH 4/5] Replace provider message data with generic function metadata --- docs/FUNCTION_METADATA.md | 45 ++++++ docs/PROVIDER_DATA.md | 52 ------- src/Messages/DTO/MessagePart.php | 54 +------- src/Messages/DTO/ProviderData.php | 129 ------------------ src/Messages/Enums/MessagePartTypeEnum.php | 7 - src/Tools/DTO/FunctionDeclaration.php | 46 ++++++- tests/unit/Messages/DTO/MessagePartTest.php | 99 +------------- tests/unit/Messages/DTO/ProviderDataTest.php | 54 -------- .../Enums/MessagePartTypeEnumTest.php | 5 - .../Tools/DTO/FunctionDeclarationTest.php | 79 +++++++++++ 10 files changed, 172 insertions(+), 398 deletions(-) create mode 100644 docs/FUNCTION_METADATA.md delete mode 100644 docs/PROVIDER_DATA.md delete mode 100644 src/Messages/DTO/ProviderData.php delete mode 100644 tests/unit/Messages/DTO/ProviderDataTest.php diff --git a/docs/FUNCTION_METADATA.md b/docs/FUNCTION_METADATA.md new file mode 100644 index 00000000..efd97b72 --- /dev/null +++ b/docs/FUNCTION_METADATA.md @@ -0,0 +1,45 @@ +# Function declaration metadata + +`FunctionDeclaration` accepts an optional fourth constructor argument containing +generic metadata (annotations): + +```php +$function = new FunctionDeclaration( + 'get_weather', + 'Gets the weather', + null, + ['deferredLoading' => true] +); + +$metadata = $function->getMetadata(); +``` + +Metadata is an `array` whose values should be JSON-serializable. +The SDK preserves it during declaration and model-configuration serialization +without interpreting annotation names or values. Empty metadata is omitted from +serialized declarations, preserving the existing shape for callers that do not +use this argument. Missing metadata is restored as an empty array. + +Providers and other consumers define which annotations they recognize, their value +types, and their interaction with request-level custom options. Unknown annotations +can be ignored. Provider-specific annotations should use a namespaced key or nested +provider-specific map to avoid collisions. Metadata must not be blindly merged into +a provider request or the function's JSON parameter schema. + +For example, a provider can interpret a `deferredLoading` annotation together with +`ModelConfig::setCustomOptions(['deferredLoading' => true])`. This example does not +establish a core deferred-loading capability, guarantee provider support, or add a +model-selection requirement. Automatic tool-count thresholds remain provider policy. +Annotations are not authorization; applications must still validate tool execution. + +This extension concerns outgoing function definitions only. It adds no message +types or native response replay mechanism. An OpenAI provider experiment can use +existing `previous_response_id` custom options and send only new input for +server-managed continuation; stateless replay of discovery items remains separate +work requiring further evidence. + +The [Vercel AI SDK OpenAI provider](https://ai-sdk.dev/providers/ai-sdk-providers/openai) +uses per-tool `providerOptions` for comparable provider-owned configuration. Its +documented deferred-loading API is explicit, rather than count-triggered. Generic +metadata provides an extension point for exploring such features without adding +feature-specific properties or fluent builder methods to this SDK. diff --git a/docs/PROVIDER_DATA.md b/docs/PROVIDER_DATA.md deleted file mode 100644 index 22ad92d0..00000000 --- a/docs/PROVIDER_DATA.md +++ /dev/null @@ -1,52 +0,0 @@ -# Provider-native conversation data - -`ProviderData` lets a provider retain a native conversation item as an ordered -`MessagePart` without adding that provider's wire format to the SDK. It is opaque -to the core client. Array/JSON serialization and cloning preserve it alongside -ordinary text and function-call parts. - -Providers must check the originating provider ID and validate the payload before -replaying it. A provider ID is an ownership label, not authentication: persisted -messages and tool execution still need application-level authorization. Providers -that do not recognize this part type must not interpret it as text or execute it -as an application function call. - -Applications should preserve all returned message parts when saving history. -Converting a result to text loses non-text conversation state. - -## Why this small core extension? - -The motivating consumer is hosted tool search in the OpenAI provider. The provider -can choose an automatic tool-count threshold, emit `defer_loading` and -`tool_search`, and consume policy overrides through the existing -`ModelConfig::setCustomOption()` API. None of that needs a core tool-search option, -a `FunctionDeclaration` flag, or a `PromptBuilder::usingToolSearch()` method. - -Request parameters alone do not preserve the search call and loaded-tool output -across stateless, serialized message history. Result-level provider metadata is -not retained by message conversion; thought signatures describe reasoning, not -tool discovery. `ProviderData` addresses only that conversation-state gap. - -Using `previous_response_id` through custom options is a zero-core-change -alternative when the application deliberately uses OpenAI-managed conversation -state. It requires the application to retain response IDs and send only new input, -and is not a substitute for portable, stateless message history. - -## Research (2026-09-09) - -- [OpenAI tool search](https://developers.openai.com/api/docs/guides/tools-tool-search) - supports hosted discovery on GPT-5.4 and later compatible Responses models. - Deferred flat functions retain their names/descriptions in context and defer - mainly parameter schemas. Namespaces can yield larger savings, but should not - be invented automatically without meaningful grouping metadata. -- [Vercel AI SDK v7 OpenAI provider](https://ai-sdk.dev/providers/ai-sdk-providers/openai) - uses an explicit provider tool plus per-function `providerOptions.openai.deferLoading`. - It preserves provider-specific replay metadata. It does not document an automatic - tool-count activation threshold. -- [Vercel Anthropic provider](https://ai-sdk.dev/providers/ai-sdk-providers/anthropic) - likewise exposes provider-defined search tools and deferred-loading options. - -An automatic threshold such as more than 10 functions is a provider experiment, -not a cross-provider capability guarantee or a demonstrated performance win. -Measure task success, total tokens, and end-to-end latency against eager loading -before promoting that policy or further tool-search APIs into core. diff --git a/src/Messages/DTO/MessagePart.php b/src/Messages/DTO/MessagePart.php index d90ee3e5..228471be 100644 --- a/src/Messages/DTO/MessagePart.php +++ b/src/Messages/DTO/MessagePart.php @@ -24,7 +24,6 @@ * @phpstan-import-type FileArrayShape from File * @phpstan-import-type FunctionCallArrayShape from FunctionCall * @phpstan-import-type FunctionResponseArrayShape from FunctionResponse - * @phpstan-import-type ProviderDataArrayShape from ProviderData * * @phpstan-type MessagePartArrayShape array{ * channel: string, @@ -33,8 +32,7 @@ * text?: string, * file?: FileArrayShape, * functionCall?: FunctionCallArrayShape, - * functionResponse?: FunctionResponseArrayShape, - * providerData?: ProviderDataArrayShape + * functionResponse?: FunctionResponseArrayShape * } * * @extends AbstractDataTransferObject @@ -48,7 +46,6 @@ class MessagePart extends AbstractDataTransferObject public const KEY_FILE = 'file'; public const KEY_FUNCTION_CALL = 'functionCall'; public const KEY_FUNCTION_RESPONSE = 'functionResponse'; - public const KEY_PROVIDER_DATA = 'providerData'; /** * @var MessagePartChannelEnum The channel this message part belongs to. @@ -85,11 +82,6 @@ class MessagePart extends AbstractDataTransferObject */ private ?FunctionResponse $functionResponse = null; - /** - * @var ProviderData|null Opaque provider-native data (when type is PROVIDER_DATA). - */ - private ?ProviderData $providerData = null; - /** * Constructor that accepts various content types and infers the message part type. * @@ -117,15 +109,12 @@ public function __construct($content, ?MessagePartChannelEnum $channel = null, ? } elseif ($content instanceof FunctionResponse) { $this->type = MessagePartTypeEnum::functionResponse(); $this->functionResponse = $content; - } elseif ($content instanceof ProviderData) { - $this->type = MessagePartTypeEnum::providerData(); - $this->providerData = $content; } else { $type = is_object($content) ? get_class($content) : gettype($content); throw new InvalidArgumentException( sprintf( 'Unsupported content type %s. Expected string, File, ' - . 'FunctionCall, FunctionResponse, or ProviderData.', + . 'FunctionCall, or FunctionResponse.', $type ) ); @@ -216,18 +205,6 @@ public function getFunctionResponse(): ?FunctionResponse return $this->functionResponse; } - /** - * Gets the opaque provider-native data. - * - * @since n.e.x.t - * - * @return ProviderData|null The provider-native data or null if not a provider data part. - */ - public function getProviderData(): ?ProviderData - { - return $this->providerData; - } - /** * {@inheritDoc} * @@ -307,20 +284,6 @@ public static function getJsonSchema(): array 'required' => [self::KEY_TYPE, self::KEY_FUNCTION_RESPONSE], 'additionalProperties' => false, ], - [ - 'type' => 'object', - 'properties' => [ - self::KEY_CHANNEL => $channelSchema, - self::KEY_TYPE => [ - 'type' => 'string', - 'const' => MessagePartTypeEnum::providerData()->value, - ], - self::KEY_PROVIDER_DATA => ProviderData::getJsonSchema(), - self::KEY_THOUGHT_SIGNATURE => $thoughtSignatureSchema, - ], - 'required' => [self::KEY_TYPE, self::KEY_PROVIDER_DATA], - 'additionalProperties' => false, - ], ], ]; } @@ -347,11 +310,9 @@ public function toArray(): array $data[self::KEY_FUNCTION_CALL] = $this->functionCall->toArray(); } elseif ($this->functionResponse !== null) { $data[self::KEY_FUNCTION_RESPONSE] = $this->functionResponse->toArray(); - } elseif ($this->providerData !== null) { - $data[self::KEY_PROVIDER_DATA] = $this->providerData->toArray(); } else { throw new RuntimeException( - 'MessagePart requires one of: text, file, functionCall, functionResponse, or providerData. ' + 'MessagePart requires one of: text, file, functionCall, or functionResponse. ' . 'This should not be a possible condition.' ); } @@ -391,11 +352,9 @@ public static function fromArray(array $array): self $channel, $thoughtSignature ); - } elseif (isset($array[self::KEY_PROVIDER_DATA])) { - return new self(ProviderData::fromArray($array[self::KEY_PROVIDER_DATA]), $channel, $thoughtSignature); } else { throw new InvalidArgumentException( - 'MessagePart requires one of: text, file, functionCall, functionResponse, or providerData.' + 'MessagePart requires one of: text, file, functionCall, or functionResponse.' ); } } @@ -403,7 +362,7 @@ public static function fromArray(array $array): self /** * Performs a deep clone of the message part. * - * This method ensures that nested objects (file, function call, function response, provider data) + * This method ensures that nested objects (file, function call, function response) * are cloned to prevent modifications to the cloned part from affecting the original. * * @since 0.4.2 @@ -419,8 +378,5 @@ public function __clone() if ($this->functionResponse !== null) { $this->functionResponse = clone $this->functionResponse; } - if ($this->providerData !== null) { - $this->providerData = clone $this->providerData; - } } } diff --git a/src/Messages/DTO/ProviderData.php b/src/Messages/DTO/ProviderData.php deleted file mode 100644 index 7baa88a5..00000000 --- a/src/Messages/DTO/ProviderData.php +++ /dev/null @@ -1,129 +0,0 @@ - - * } - * - * @extends AbstractDataTransferObject - */ -class ProviderData extends AbstractDataTransferObject -{ - public const KEY_PROVIDER_ID = 'providerId'; - public const KEY_DATA = 'data'; - - /** - * @var string The identifier of the provider that owns this data. - */ - private string $providerId; - - /** - * @var array The opaque provider-native data. - */ - private array $data; - - /** - * Constructor. - * - * @since n.e.x.t - * - * @param string $providerId The identifier of the provider that owns this data. - * @param array $data The opaque provider-native data. - */ - public function __construct(string $providerId, array $data) - { - $this->providerId = $providerId; - $this->data = $data; - } - - /** - * Gets the identifier of the provider that owns this data. - * - * @since n.e.x.t - * - * @return string The provider identifier. - */ - public function getProviderId(): string - { - return $this->providerId; - } - - /** - * Gets the opaque provider-native data. - * - * @since n.e.x.t - * - * @return array The provider-native data. - */ - public function getData(): array - { - return $this->data; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public static function getJsonSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - self::KEY_PROVIDER_ID => [ - 'type' => 'string', - 'description' => 'The identifier of the provider that owns this data.', - ], - self::KEY_DATA => [ - 'type' => 'object', - 'additionalProperties' => true, - 'description' => 'Opaque provider-native message data.', - ], - ], - 'required' => [self::KEY_PROVIDER_ID, self::KEY_DATA], - 'additionalProperties' => false, - ]; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - * - * @return ProviderDataArrayShape - */ - public function toArray(): array - { - return [ - self::KEY_PROVIDER_ID => $this->providerId, - self::KEY_DATA => $this->data, - ]; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public static function fromArray(array $array): self - { - static::validateFromArrayData($array, [self::KEY_PROVIDER_ID, self::KEY_DATA]); - - return new self($array[self::KEY_PROVIDER_ID], $array[self::KEY_DATA]); - } -} diff --git a/src/Messages/Enums/MessagePartTypeEnum.php b/src/Messages/Enums/MessagePartTypeEnum.php index 6466555f..ed7b7064 100644 --- a/src/Messages/Enums/MessagePartTypeEnum.php +++ b/src/Messages/Enums/MessagePartTypeEnum.php @@ -15,12 +15,10 @@ * @method static self file() Creates an instance for FILE type. * @method static self functionCall() Creates an instance for FUNCTION_CALL type. * @method static self functionResponse() Creates an instance for FUNCTION_RESPONSE type. - * @method static self providerData() Creates an instance for PROVIDER_DATA type. * @method bool isText() Checks if the type is TEXT. * @method bool isFile() Checks if the type is FILE. * @method bool isFunctionCall() Checks if the type is FUNCTION_CALL. * @method bool isFunctionResponse() Checks if the type is FUNCTION_RESPONSE. - * @method bool isProviderData() Checks if the type is PROVIDER_DATA. */ class MessagePartTypeEnum extends AbstractEnum { @@ -43,9 +41,4 @@ class MessagePartTypeEnum extends AbstractEnum * Function response. */ public const FUNCTION_RESPONSE = 'function_response'; - - /** - * Opaque provider-native message data. - */ - public const PROVIDER_DATA = 'provider_data'; } diff --git a/src/Tools/DTO/FunctionDeclaration.php b/src/Tools/DTO/FunctionDeclaration.php index 5b08ce80..5f51d197 100644 --- a/src/Tools/DTO/FunctionDeclaration.php +++ b/src/Tools/DTO/FunctionDeclaration.php @@ -10,14 +10,15 @@ * Represents a function declaration for AI models. * * This DTO describes a function that can be called by the AI model, - * including its name, description, and parameter schema. + * including its name, description, parameter schema, and optional metadata. * * @since 0.1.0 * * @phpstan-type FunctionDeclarationArrayShape array{ * name: string, * description: string, - * parameters?: array + * parameters?: array, + * metadata?: array * } * * @extends AbstractDataTransferObject @@ -27,6 +28,7 @@ class FunctionDeclaration extends AbstractDataTransferObject public const KEY_NAME = 'name'; public const KEY_DESCRIPTION = 'description'; public const KEY_PARAMETERS = 'parameters'; + public const KEY_METADATA = 'metadata'; /** * @var string The name of the function. */ @@ -42,20 +44,32 @@ class FunctionDeclaration extends AbstractDataTransferObject */ private ?array $parameters; + /** + * @var array Optional annotations interpreted by consumers, not the core SDK. + */ + private array $metadata; + /** * Constructor. * * @since 0.1.0 + * @since n.e.x.t Adds the optional $metadata parameter. * * @param string $name The name of the function. * @param string $description A description of what the function does. * @param array|null $parameters The JSON schema for the function parameters. + * @param array $metadata Optional metadata with JSON-serializable values. */ - public function __construct(string $name, string $description, ?array $parameters = null) - { + public function __construct( + string $name, + string $description, + ?array $parameters = null, + array $metadata = [] + ) { $this->name = $name; $this->description = $description; $this->parameters = $parameters; + $this->metadata = $metadata; } /** @@ -94,6 +108,18 @@ public function getParameters(): ?array return $this->parameters; } + /** + * Gets the function metadata without interpreting its annotations. + * + * @since n.e.x.t + * + * @return array The metadata, or an empty array if none was provided. + */ + public function getMetadata(): array + { + return $this->metadata; + } + /** * {@inheritDoc} * @@ -117,6 +143,11 @@ public static function getJsonSchema(): array 'description' => 'The JSON schema for the function parameters.', 'additionalProperties' => true, ], + self::KEY_METADATA => [ + 'type' => 'object', + 'description' => 'Optional metadata whose annotations are interpreted by consumers.', + 'additionalProperties' => true, + ], ], 'required' => [self::KEY_NAME, self::KEY_DESCRIPTION], ]; @@ -140,6 +171,10 @@ public function toArray(): array $data[self::KEY_PARAMETERS] = $this->parameters; } + if ($this->metadata !== []) { + $data[self::KEY_METADATA] = $this->metadata; + } + return $data; } @@ -155,7 +190,8 @@ public static function fromArray(array $array): self return new self( $array[self::KEY_NAME], $array[self::KEY_DESCRIPTION], - $array[self::KEY_PARAMETERS] ?? null + $array[self::KEY_PARAMETERS] ?? null, + $array[self::KEY_METADATA] ?? [] ); } } diff --git a/tests/unit/Messages/DTO/MessagePartTest.php b/tests/unit/Messages/DTO/MessagePartTest.php index 7cd39bdf..de7a4ae0 100644 --- a/tests/unit/Messages/DTO/MessagePartTest.php +++ b/tests/unit/Messages/DTO/MessagePartTest.php @@ -8,11 +8,9 @@ use PHPUnit\Framework\TestCase; use stdClass; use WordPress\AiClient\Common\Contracts\WithArrayTransformationInterface; -use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Files\Enums\FileTypeEnum; use WordPress\AiClient\Messages\DTO\MessagePart; -use WordPress\AiClient\Messages\DTO\ProviderData; use WordPress\AiClient\Messages\Enums\MessagePartChannelEnum; use WordPress\AiClient\Messages\Enums\MessagePartTypeEnum; use WordPress\AiClient\Tools\DTO\FunctionCall; @@ -95,25 +93,6 @@ public function testCreateWithFunctionResponseContent(): void $this->assertSame($functionResponse, $part->getFunctionResponse()); } - /** - * Tests creating MessagePart with opaque provider data. - * - * @return void - */ - public function testCreateWithProviderDataContent(): void - { - $providerData = new ProviderData('openai', ['type' => 'tool_search_call', 'id' => 'search_123']); - $part = new MessagePart($providerData); - - $this->assertEquals(MessagePartTypeEnum::providerData(), $part->getType()); - $this->assertEquals(MessagePartChannelEnum::content(), $part->getChannel()); - $this->assertSame($providerData, $part->getProviderData()); - $this->assertNull($part->getText()); - $this->assertNull($part->getFile()); - $this->assertNull($part->getFunctionCall()); - $this->assertNull($part->getFunctionResponse()); - } - /** * Tests creating MessagePart with empty string. * @@ -140,7 +119,7 @@ public function testUnsupportedContentThrowsException($content, string $expected { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage(sprintf( - 'Unsupported content type %s. Expected string, File, FunctionCall, FunctionResponse, or ProviderData.', + 'Unsupported content type %s. Expected string, File, FunctionCall, or FunctionResponse.', $expectedType )); @@ -164,44 +143,6 @@ public function unsupportedContentProvider(): array ]; } - /** - * Tests toArray rejects an internally invalid part without content. - * - * @return void - */ - public function testToArrayWithoutContentThrowsException(): void - { - $part = new MessagePart('content'); - $textProperty = new \ReflectionProperty(MessagePart::class, 'text'); - $textProperty->setAccessible(true); - $textProperty->setValue($part, null); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage( - 'MessagePart requires one of: text, file, functionCall, functionResponse, or providerData.' - ); - - $part->toArray(); - } - - /** - * Tests fromArray rejects data without supported content. - * - * @return void - */ - public function testFromArrayWithoutContentThrowsException(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'MessagePart requires one of: text, file, functionCall, functionResponse, or providerData.' - ); - - MessagePart::fromArray([ - MessagePart::KEY_CHANNEL => MessagePartChannelEnum::content()->value, - MessagePart::KEY_TYPE => MessagePartTypeEnum::providerData()->value, - ]); - } - /** * Tests JSON schema. * @@ -213,7 +154,7 @@ public function testJsonSchema(): void $this->assertIsArray($schema); $this->assertArrayHasKey('oneOf', $schema); - $this->assertCount(5, $schema['oneOf']); + $this->assertCount(4, $schema['oneOf']); // text, file, function_call, function_response // Check text variant $textSchema = $schema['oneOf'][0]; @@ -257,18 +198,6 @@ public function testJsonSchema(): void [MessagePart::KEY_TYPE, MessagePart::KEY_FUNCTION_RESPONSE], $functionResponseSchema['required'] ); - - // Check provider_data variant - $providerDataSchema = $schema['oneOf'][4]; - $this->assertEquals( - MessagePartTypeEnum::providerData()->value, - $providerDataSchema['properties'][MessagePart::KEY_TYPE]['const'] - ); - $this->assertArrayHasKey(MessagePart::KEY_PROVIDER_DATA, $providerDataSchema['properties']); - $this->assertEquals( - [MessagePart::KEY_TYPE, MessagePart::KEY_PROVIDER_DATA], - $providerDataSchema['required'] - ); } /** @@ -349,7 +278,6 @@ public function testToArrayWithText(): void $this->assertArrayNotHasKey(MessagePart::KEY_FILE, $json); $this->assertArrayNotHasKey(MessagePart::KEY_FUNCTION_CALL, $json); $this->assertArrayNotHasKey(MessagePart::KEY_FUNCTION_RESPONSE, $json); - $this->assertArrayNotHasKey(MessagePart::KEY_PROVIDER_DATA, $json); } /** @@ -449,14 +377,6 @@ public function testArrayRoundTrip(): void $this->assertEquals($functionCall->getName(), $restoredFunc->getFunctionCall()->getName()); $this->assertEquals($functionCall->getArgs(), $restoredFunc->getFunctionCall()->getArgs()); $this->assertEquals($funcPart->getChannel(), $restoredFunc->getChannel()); - - // Test with provider data - $providerData = new ProviderData('anthropic', ['type' => 'tool_reference', 'tool_name' => 'calendar']); - $providerPart = new MessagePart($providerData); - $providerJson = $providerPart->toArray(); - $restoredProvider = MessagePart::fromArray($providerJson); - $this->assertEquals($providerData->getProviderId(), $restoredProvider->getProviderData()->getProviderId()); - $this->assertEquals($providerData->getData(), $restoredProvider->getProviderData()->getData()); } /** @@ -567,21 +487,6 @@ public function testCloneClonesFunctionResponse(): void $this->assertNotSame($original->getFunctionResponse(), $cloned->getFunctionResponse()); } - /** - * Tests that cloning MessagePart with ProviderData creates an independent copy. - * - * @return void - */ - public function testCloneClonesProviderData(): void - { - $providerData = new ProviderData('openai', ['type' => 'tool_search_output']); - $original = new MessagePart($providerData); - $cloned = clone $original; - - $this->assertNotSame($original->getProviderData(), $cloned->getProviderData()); - $this->assertEquals($original->getProviderData(), $cloned->getProviderData()); - } - /** * Tests creating MessagePart with a thought signature. * diff --git a/tests/unit/Messages/DTO/ProviderDataTest.php b/tests/unit/Messages/DTO/ProviderDataTest.php deleted file mode 100644 index 9b2ddd26..00000000 --- a/tests/unit/Messages/DTO/ProviderDataTest.php +++ /dev/null @@ -1,54 +0,0 @@ - 'tool_search_call', - 'id' => 'search_123', - 'arguments' => ['query' => 'calendar'], - ]; - $providerData = new ProviderData('openai', $data); - - $array = $providerData->toArray(); - $restored = ProviderData::fromArray($array); - - $this->assertEquals('openai', $restored->getProviderId()); - $this->assertEquals($data, $restored->getData()); - $this->assertEquals($array, $restored->toArray()); - } - - /** - * Tests the JSON schema describes provider-scoped opaque data. - * - * @return void - */ - public function testJsonSchema(): void - { - $schema = ProviderData::getJsonSchema(); - - $this->assertEquals('object', $schema['type']); - $this->assertEquals( - [ProviderData::KEY_PROVIDER_ID, ProviderData::KEY_DATA], - $schema['required'] - ); - $this->assertTrue($schema['properties'][ProviderData::KEY_DATA]['additionalProperties']); - $this->assertFalse($schema['additionalProperties']); - } -} diff --git a/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php b/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php index 8e7a6731..35860816 100644 --- a/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php +++ b/tests/unit/Messages/Enums/MessagePartTypeEnumTest.php @@ -37,7 +37,6 @@ protected function getExpectedValues(): array 'FILE' => 'file', 'FUNCTION_CALL' => 'function_call', 'FUNCTION_RESPONSE' => 'function_response', - 'PROVIDER_DATA' => 'provider_data', ]; } @@ -59,9 +58,5 @@ public function testSpecificEnumMethods(): void $functionCall = MessagePartTypeEnum::functionCall(); $this->assertTrue($functionCall->isFunctionCall()); $this->assertFalse($functionCall->isFunctionResponse()); - - $providerData = MessagePartTypeEnum::providerData(); - $this->assertTrue($providerData->isProviderData()); - $this->assertFalse($providerData->isText()); } } diff --git a/tests/unit/Tools/DTO/FunctionDeclarationTest.php b/tests/unit/Tools/DTO/FunctionDeclarationTest.php index b2782bdb..e6ad6422 100644 --- a/tests/unit/Tools/DTO/FunctionDeclarationTest.php +++ b/tests/unit/Tools/DTO/FunctionDeclarationTest.php @@ -5,6 +5,7 @@ namespace WordPress\AiClient\Tests\unit\Tools\DTO; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Tests\traits\ArrayTransformationTestTrait; use WordPress\AiClient\Tools\DTO\FunctionDeclaration; @@ -320,4 +321,82 @@ public function testImplementsWithArrayTransformationInterface(): void $declaration = new FunctionDeclaration('test', 'test function'); $this->assertImplementsArrayTransformation($declaration); } + + /** + * Tests legacy declarations keep their serialized shape without empty metadata. + * + * @return void + */ + public function testMetadataDefaultsPreserveCompatibility(): void + { + $legacy = ['name' => 'get_weather', 'description' => 'Gets the weather']; + $declarations = [ + new FunctionDeclaration('get_weather', 'Gets the weather'), + new FunctionDeclaration('get_weather', 'Gets the weather', null, []), + FunctionDeclaration::fromArray($legacy), + ]; + foreach ($declarations as $declaration) { + $this->assertSame([], $declaration->getMetadata()); + $this->assertSame($legacy, $declaration->toArray()); + $this->assertSame($legacy, json_decode((string) json_encode($declaration), true)); + } + } + + /** + * Tests arbitrary annotations survive array and JSON serialization unchanged. + * + * @return void + */ + public function testMetadataRoundTrip(): void + { + $metadata = [ + 'deferredLoading' => true, + 'readOnlyHint' => false, + 'vendor' => ['labels' => ['weather', 'public'], 'priority' => 0, 'optional' => null], + ]; + $declaration = new FunctionDeclaration('get_weather', 'Gets the weather', null, $metadata); + $this->assertSame($metadata, $declaration->getMetadata()); + $this->assertNull($declaration->getParameters()); + $this->assertSame($metadata, $declaration->toArray()['metadata']); + $this->assertSame($metadata, FunctionDeclaration::fromArray($declaration->toArray())->getMetadata()); + + $json = json_decode((string) json_encode($declaration), true); + $this->assertSame($metadata, FunctionDeclaration::fromArray($json)->getMetadata()); + } + + /** + * Tests metadata is optional and unconstrained in the declaration schema. + * + * @return void + */ + public function testMetadataSchema(): void + { + $schema = FunctionDeclaration::getJsonSchema(); + $this->assertSame('object', $schema['properties']['metadata']['type']); + $this->assertTrue($schema['properties']['metadata']['additionalProperties']); + $this->assertNotContains('metadata', $schema['required']); + } + + /** + * Tests model configuration preserves metadata and clones declarations independently. + * + * @return void + */ + public function testMetadataSurvivesModelConfigRoundTripAndClone(): void + { + $metadata = ['vendor' => ['enabled' => false]]; + $declaration = new FunctionDeclaration('lookup', 'Looks up a record', ['type' => 'object'], $metadata); + $config = new ModelConfig(); + $config->setFunctionDeclarations([$declaration]); + $restored = ModelConfig::fromArray($config->toArray()); + $cloned = clone $config; + $this->assertSame($metadata, $restored->getFunctionDeclarations()[0]->getMetadata()); + $this->assertSame($metadata, $cloned->getFunctionDeclarations()[0]->getMetadata()); + $this->assertNotSame($declaration, $cloned->getFunctionDeclarations()[0]); + + $copy = $cloned->getFunctionDeclarations()[0]->getMetadata(); + $copy['vendor']['enabled'] = true; + $this->assertSame($metadata, $declaration->getMetadata()); + $this->assertSame($metadata, $cloned->getFunctionDeclarations()[0]->getMetadata()); + } } From 0b1af53a2a1d31259ea90bdb575ea0b0d90fd13e Mon Sep 17 00:00:00 2001 From: David Stone Date: Wed, 9 Sep 2026 18:41:02 -0600 Subject: [PATCH 5/5] Rename function metadata to annotations --- docs/ARCHITECTURE.md | 21 ++++++++ docs/FUNCTION_METADATA.md | 45 ----------------- src/Tools/DTO/FunctionDeclaration.php | 36 +++++++------- .../Tools/DTO/FunctionDeclarationTest.php | 48 +++++++++---------- 4 files changed, 63 insertions(+), 87 deletions(-) delete mode 100644 docs/FUNCTION_METADATA.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 07cf44b6..f6635276 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -340,6 +340,25 @@ $embeddings = AiClient::generateEmbeddings( ); ``` +### Function declaration annotations + +`FunctionDeclaration` accepts optional annotations for consumer-specific hints that do not belong to the function's parameter schema: + +```php +$function = new FunctionDeclaration( + 'get_weather', + 'Gets the weather', + null, + ['deferredLoading' => true] +); + +$annotations = $function->getAnnotations(); +``` + +Annotations are an `array` whose values should be JSON-serializable. The core SDK preserves annotations without interpreting their names or values. Providers and other consumers define the annotations they recognize and ignore unknown annotations. Provider-specific annotations should use namespaced keys or a nested provider-specific map to avoid collisions, and must not be blindly merged into provider requests. + +Empty annotations are omitted from serialized declarations, preserving the existing shape for callers that do not use them. Missing annotations are restored as an empty array. Annotations are not authorization; applications must still validate tool execution. + ## Class diagrams This section shows comprehensive class diagrams for the proposed architecture. For explanation on specific terms, see the [glossary](./GLOSSARY.md). @@ -806,6 +825,7 @@ direction LR +getName() string +getDescription() string +getParameters() mixed + +getAnnotations() array< string, mixed > +getJsonSchema() array< string, mixed >$ } class FunctionResponse { @@ -1229,6 +1249,7 @@ direction LR +getName() string +getDescription() string +getParameters() mixed + +getAnnotations() array< string, mixed > +getJsonSchema() array< string, mixed >$ } class Tool { diff --git a/docs/FUNCTION_METADATA.md b/docs/FUNCTION_METADATA.md deleted file mode 100644 index efd97b72..00000000 --- a/docs/FUNCTION_METADATA.md +++ /dev/null @@ -1,45 +0,0 @@ -# Function declaration metadata - -`FunctionDeclaration` accepts an optional fourth constructor argument containing -generic metadata (annotations): - -```php -$function = new FunctionDeclaration( - 'get_weather', - 'Gets the weather', - null, - ['deferredLoading' => true] -); - -$metadata = $function->getMetadata(); -``` - -Metadata is an `array` whose values should be JSON-serializable. -The SDK preserves it during declaration and model-configuration serialization -without interpreting annotation names or values. Empty metadata is omitted from -serialized declarations, preserving the existing shape for callers that do not -use this argument. Missing metadata is restored as an empty array. - -Providers and other consumers define which annotations they recognize, their value -types, and their interaction with request-level custom options. Unknown annotations -can be ignored. Provider-specific annotations should use a namespaced key or nested -provider-specific map to avoid collisions. Metadata must not be blindly merged into -a provider request or the function's JSON parameter schema. - -For example, a provider can interpret a `deferredLoading` annotation together with -`ModelConfig::setCustomOptions(['deferredLoading' => true])`. This example does not -establish a core deferred-loading capability, guarantee provider support, or add a -model-selection requirement. Automatic tool-count thresholds remain provider policy. -Annotations are not authorization; applications must still validate tool execution. - -This extension concerns outgoing function definitions only. It adds no message -types or native response replay mechanism. An OpenAI provider experiment can use -existing `previous_response_id` custom options and send only new input for -server-managed continuation; stateless replay of discovery items remains separate -work requiring further evidence. - -The [Vercel AI SDK OpenAI provider](https://ai-sdk.dev/providers/ai-sdk-providers/openai) -uses per-tool `providerOptions` for comparable provider-owned configuration. Its -documented deferred-loading API is explicit, rather than count-triggered. Generic -metadata provides an extension point for exploring such features without adding -feature-specific properties or fluent builder methods to this SDK. diff --git a/src/Tools/DTO/FunctionDeclaration.php b/src/Tools/DTO/FunctionDeclaration.php index 5f51d197..2b148636 100644 --- a/src/Tools/DTO/FunctionDeclaration.php +++ b/src/Tools/DTO/FunctionDeclaration.php @@ -10,7 +10,7 @@ * Represents a function declaration for AI models. * * This DTO describes a function that can be called by the AI model, - * including its name, description, parameter schema, and optional metadata. + * including its name, description, parameter schema, and optional annotations. * * @since 0.1.0 * @@ -18,7 +18,7 @@ * name: string, * description: string, * parameters?: array, - * metadata?: array + * annotations?: array * } * * @extends AbstractDataTransferObject @@ -28,7 +28,7 @@ class FunctionDeclaration extends AbstractDataTransferObject public const KEY_NAME = 'name'; public const KEY_DESCRIPTION = 'description'; public const KEY_PARAMETERS = 'parameters'; - public const KEY_METADATA = 'metadata'; + public const KEY_ANNOTATIONS = 'annotations'; /** * @var string The name of the function. */ @@ -45,31 +45,31 @@ class FunctionDeclaration extends AbstractDataTransferObject private ?array $parameters; /** - * @var array Optional annotations interpreted by consumers, not the core SDK. + * @var array The function annotations. */ - private array $metadata; + private array $annotations; /** * Constructor. * * @since 0.1.0 - * @since n.e.x.t Adds the optional $metadata parameter. + * @since n.e.x.t Adds the optional $annotations parameter. * * @param string $name The name of the function. * @param string $description A description of what the function does. * @param array|null $parameters The JSON schema for the function parameters. - * @param array $metadata Optional metadata with JSON-serializable values. + * @param array $annotations Optional annotations with JSON-serializable values. */ public function __construct( string $name, string $description, ?array $parameters = null, - array $metadata = [] + array $annotations = [] ) { $this->name = $name; $this->description = $description; $this->parameters = $parameters; - $this->metadata = $metadata; + $this->annotations = $annotations; } /** @@ -109,15 +109,15 @@ public function getParameters(): ?array } /** - * Gets the function metadata without interpreting its annotations. + * Gets the function annotations. * * @since n.e.x.t * - * @return array The metadata, or an empty array if none was provided. + * @return array The annotations, or an empty array if none were provided. */ - public function getMetadata(): array + public function getAnnotations(): array { - return $this->metadata; + return $this->annotations; } /** @@ -143,9 +143,9 @@ public static function getJsonSchema(): array 'description' => 'The JSON schema for the function parameters.', 'additionalProperties' => true, ], - self::KEY_METADATA => [ + self::KEY_ANNOTATIONS => [ 'type' => 'object', - 'description' => 'Optional metadata whose annotations are interpreted by consumers.', + 'description' => 'Optional annotations interpreted by consumers.', 'additionalProperties' => true, ], ], @@ -171,8 +171,8 @@ public function toArray(): array $data[self::KEY_PARAMETERS] = $this->parameters; } - if ($this->metadata !== []) { - $data[self::KEY_METADATA] = $this->metadata; + if ($this->annotations !== []) { + $data[self::KEY_ANNOTATIONS] = $this->annotations; } return $data; @@ -191,7 +191,7 @@ public static function fromArray(array $array): self $array[self::KEY_NAME], $array[self::KEY_DESCRIPTION], $array[self::KEY_PARAMETERS] ?? null, - $array[self::KEY_METADATA] ?? [] + $array[self::KEY_ANNOTATIONS] ?? [] ); } } diff --git a/tests/unit/Tools/DTO/FunctionDeclarationTest.php b/tests/unit/Tools/DTO/FunctionDeclarationTest.php index e6ad6422..f6f8bb85 100644 --- a/tests/unit/Tools/DTO/FunctionDeclarationTest.php +++ b/tests/unit/Tools/DTO/FunctionDeclarationTest.php @@ -323,11 +323,11 @@ public function testImplementsWithArrayTransformationInterface(): void } /** - * Tests legacy declarations keep their serialized shape without empty metadata. + * Tests legacy declarations keep their serialized shape without empty annotations. * * @return void */ - public function testMetadataDefaultsPreserveCompatibility(): void + public function testAnnotationsDefaultsPreserveCompatibility(): void { $legacy = ['name' => 'get_weather', 'description' => 'Gets the weather']; $declarations = [ @@ -336,7 +336,7 @@ public function testMetadataDefaultsPreserveCompatibility(): void FunctionDeclaration::fromArray($legacy), ]; foreach ($declarations as $declaration) { - $this->assertSame([], $declaration->getMetadata()); + $this->assertSame([], $declaration->getAnnotations()); $this->assertSame($legacy, $declaration->toArray()); $this->assertSame($legacy, json_decode((string) json_encode($declaration), true)); } @@ -347,56 +347,56 @@ public function testMetadataDefaultsPreserveCompatibility(): void * * @return void */ - public function testMetadataRoundTrip(): void + public function testAnnotationsRoundTrip(): void { - $metadata = [ + $annotations = [ 'deferredLoading' => true, 'readOnlyHint' => false, 'vendor' => ['labels' => ['weather', 'public'], 'priority' => 0, 'optional' => null], ]; - $declaration = new FunctionDeclaration('get_weather', 'Gets the weather', null, $metadata); - $this->assertSame($metadata, $declaration->getMetadata()); + $declaration = new FunctionDeclaration('get_weather', 'Gets the weather', null, $annotations); + $this->assertSame($annotations, $declaration->getAnnotations()); $this->assertNull($declaration->getParameters()); - $this->assertSame($metadata, $declaration->toArray()['metadata']); - $this->assertSame($metadata, FunctionDeclaration::fromArray($declaration->toArray())->getMetadata()); + $this->assertSame($annotations, $declaration->toArray()['annotations']); + $this->assertSame($annotations, FunctionDeclaration::fromArray($declaration->toArray())->getAnnotations()); $json = json_decode((string) json_encode($declaration), true); - $this->assertSame($metadata, FunctionDeclaration::fromArray($json)->getMetadata()); + $this->assertSame($annotations, FunctionDeclaration::fromArray($json)->getAnnotations()); } /** - * Tests metadata is optional and unconstrained in the declaration schema. + * Tests annotations are optional and unconstrained in the declaration schema. * * @return void */ - public function testMetadataSchema(): void + public function testAnnotationsSchema(): void { $schema = FunctionDeclaration::getJsonSchema(); - $this->assertSame('object', $schema['properties']['metadata']['type']); - $this->assertTrue($schema['properties']['metadata']['additionalProperties']); - $this->assertNotContains('metadata', $schema['required']); + $this->assertSame('object', $schema['properties']['annotations']['type']); + $this->assertTrue($schema['properties']['annotations']['additionalProperties']); + $this->assertNotContains('annotations', $schema['required']); } /** - * Tests model configuration preserves metadata and clones declarations independently. + * Tests model configuration preserves annotations and clones declarations independently. * * @return void */ - public function testMetadataSurvivesModelConfigRoundTripAndClone(): void + public function testAnnotationsSurviveModelConfigRoundTripAndClone(): void { - $metadata = ['vendor' => ['enabled' => false]]; - $declaration = new FunctionDeclaration('lookup', 'Looks up a record', ['type' => 'object'], $metadata); + $annotations = ['vendor' => ['enabled' => false]]; + $declaration = new FunctionDeclaration('lookup', 'Looks up a record', ['type' => 'object'], $annotations); $config = new ModelConfig(); $config->setFunctionDeclarations([$declaration]); $restored = ModelConfig::fromArray($config->toArray()); $cloned = clone $config; - $this->assertSame($metadata, $restored->getFunctionDeclarations()[0]->getMetadata()); - $this->assertSame($metadata, $cloned->getFunctionDeclarations()[0]->getMetadata()); + $this->assertSame($annotations, $restored->getFunctionDeclarations()[0]->getAnnotations()); + $this->assertSame($annotations, $cloned->getFunctionDeclarations()[0]->getAnnotations()); $this->assertNotSame($declaration, $cloned->getFunctionDeclarations()[0]); - $copy = $cloned->getFunctionDeclarations()[0]->getMetadata(); + $copy = $cloned->getFunctionDeclarations()[0]->getAnnotations(); $copy['vendor']['enabled'] = true; - $this->assertSame($metadata, $declaration->getMetadata()); - $this->assertSame($metadata, $cloned->getFunctionDeclarations()[0]->getMetadata()); + $this->assertSame($annotations, $declaration->getAnnotations()); + $this->assertSame($annotations, $cloned->getFunctionDeclarations()[0]->getAnnotations()); } }