From 6691b5d82446be84b53614cba588d47482b0280c Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Mon, 19 May 2025 14:48:54 +0100 Subject: [PATCH 01/15] chore: bedrock support --- composer.json | 6 +- src/Bridge/AwsBedrock/AmazonNova.php | 66 ++++++ .../AwsBedrock/BedrockLanguageModel.php | 9 + .../AwsBedrock/BedrockRequestSigner.php | 48 +++++ .../AwsBedrock/Language/ModelClient.php | 192 +++++++++++++++++ .../AwsBedrock/Language/ResponseConverter.php | 193 ++++++++++++++++++ src/Bridge/AwsBedrock/PlatformFactory.php | 40 ++++ 7 files changed, 552 insertions(+), 2 deletions(-) create mode 100644 src/Bridge/AwsBedrock/AmazonNova.php create mode 100644 src/Bridge/AwsBedrock/BedrockLanguageModel.php create mode 100644 src/Bridge/AwsBedrock/BedrockRequestSigner.php create mode 100644 src/Bridge/AwsBedrock/Language/ModelClient.php create mode 100644 src/Bridge/AwsBedrock/Language/ResponseConverter.php create mode 100644 src/Bridge/AwsBedrock/PlatformFactory.php diff --git a/composer.json b/composer.json index 4bf61815..c4ff1455 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,8 @@ "symfony/event-dispatcher": "^6.4 || ^7.1", "symfony/finder": "^6.4 || ^7.1", "symfony/process": "^6.4 || ^7.1", - "symfony/var-dumper": "^6.4 || ^7.1" + "symfony/var-dumper": "^6.4 || ^7.1", + "aws/aws-sdk-php": "^3" }, "suggest": { "codewithkyrian/chromadb-php": "For using the ChromaDB as retrieval vector store.", @@ -64,7 +65,8 @@ "mongodb/mongodb": "For using MongoDB Atlas as retrieval vector store.", "probots-io/pinecone-php": "For using the Pinecone as retrieval vector store.", "symfony/css-selector": "For using the YouTube transcription tool.", - "symfony/dom-crawler": "For using the YouTube transcription tool." + "symfony/dom-crawler": "For using the YouTube transcription tool.", + "aws/aws-sdk-php": "For using AWS Bedrock" }, "config": { "allow-plugins": { diff --git a/src/Bridge/AwsBedrock/AmazonNova.php b/src/Bridge/AwsBedrock/AmazonNova.php new file mode 100644 index 00000000..8f257ae4 --- /dev/null +++ b/src/Bridge/AwsBedrock/AmazonNova.php @@ -0,0 +1,66 @@ + $options + * @param string|null $inferenceProfileRegion if you need to use an inference profile just add the region here + */ + public function __construct( + private string $name = self::NOVA_LITE_V1, + private array $options = [], + private ?string $inferenceProfileRegion = null + ) { + } + + public function getName(): string + { + if ($this->inferenceProfileRegion) { + return $this->inferenceProfileRegion.'.'.$this->name; + } + + return $this->name; + } + + public function getOptions(): array + { + return $this->options; + } + + public function supportsAudioInput(): bool + { + return false; // it does, but implementation here is still open. + } + + public function supportsImageInput(): bool + { + return false; // it does, but implementation here is still open. + } + + public function supportsStreaming(): bool + { + return false; // it does, but implementation here is still open. + } + + public function supportsToolCalling(): bool + { + return false; // it does, but implementation here is still open. + } + + public function supportsStructuredOutput(): bool + { + return false; // it does, but implementation here is still open. + } +} diff --git a/src/Bridge/AwsBedrock/BedrockLanguageModel.php b/src/Bridge/AwsBedrock/BedrockLanguageModel.php new file mode 100644 index 00000000..5c265c5e --- /dev/null +++ b/src/Bridge/AwsBedrock/BedrockLanguageModel.php @@ -0,0 +1,9 @@ +region); + + $uri = new Uri($endpoint); + + $finalHeaders = array_merge([ + 'Host' => $uri->getHost(), + 'Content-Type' => 'application/json', + ], $extraHeaders); + + $request = new Request( + $method, + $uri, + $finalHeaders, + $encodedBody = json_encode($jsonBody) + ); + + $signedRequest = $signature->signRequest($request, $this->credentials); + + $signedHeaders = []; + foreach ($signedRequest->getHeaders() as $name => $values) { + $signedHeaders[$name] = $signedRequest->getHeaderLine($name); + } + + unset($request, $finalHeaders, $uri, $signature); + + return [ + 'headers' => $signedHeaders, + 'body' => $encodedBody, + ]; + } +} diff --git a/src/Bridge/AwsBedrock/Language/ModelClient.php b/src/Bridge/AwsBedrock/Language/ModelClient.php new file mode 100644 index 00000000..ba339e78 --- /dev/null +++ b/src/Bridge/AwsBedrock/Language/ModelClient.php @@ -0,0 +1,192 @@ +httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + } + + public function supports(Model $model, array|string|object $input): bool + { + return $input instanceof MessageBag && $model instanceof BedrockLanguageModel; + } + + public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface + { + if ($input instanceof MessageBag) { + $systemMessagesMap = null; + + if ($systemMessage = $input->getSystemMessage()) { + $systemMessagesMap = [ + [ + 'text' => $systemMessage->content, + ], + ]; + } + + $messagesMap = array_map( + function (MessageInterface $inputEntry) { + if ($inputEntry instanceof ToolCallMessage) { + return [ + 'role' => 'user', + 'content' => [ + [ + 'toolResult' => [ + 'toolUseId' => $inputEntry->toolCall->id, + 'content' => [ + [ + 'text' => $inputEntry->content, + ], + ], + ], + ], + ], + ]; + } elseif ($inputEntry instanceof AssistantMessage) { + return [ + 'role' => 'assistant', + 'content' => [ + array_filter([ + 'text' => $inputEntry->content, + 'toolUse' => ($inputEntry->toolCalls[0] ?? null) ? [ + 'toolUseId' => $inputEntry->toolCalls[0]->id, + 'name' => $inputEntry->toolCalls[0]->name, + 'input' => $inputEntry->toolCalls[0]->arguments, + ] : null, + ], fn ($content) => !is_null($content) + ), + ], + ]; + } + + return [ + 'role' => match ($inputEntry->getRole()) { + Role::Assistant => 'assistant', + default => 'user' + }, + 'content' => [ + [ + 'text' => $inputEntry->jsonSerialize()['content'] ?? null, + ], + ], + ]; + }, + $input->withoutSystemMessage()->getMessages() + ); + + $toolConfig = null; + if (isset($options['tools'])) { + $toolConfig = [ + 'tools' => array_map( + fn (Metadata $toolConfig) => [ + 'toolSpec' => [ + 'description' => $toolConfig->description, + 'name' => $toolConfig->name, + 'inputSchema' => [ + 'json' => $toolConfig->parameters, + ], + ], + ], $options['tools'] + ), + ]; + + unset($options['tools']); + } + + $inferenceConfig = array_reduce( + array_filter( + $options, + fn ($value, $optionKey) => in_array($optionKey, ['max_tokens', 'stop_sequences', 'temperature', 'top_p']) + ), + function (array $inferenceConfigAcc, string $optionValue, string $optionKey) use (&$options) { + unset($options[$optionKey]); + + $optionKey = implode( + '', + array_map( + fn ($part, $partIndex) => 0 === $partIndex ? mb_lcfirst($part) : mb_ucfirst($part), + explode('_', $optionKey) + ) + ); + + $inferenceConfigAcc[$optionKey] = $optionValue; + + return $inferenceConfigAcc; + }, + [] + ); + + $additionalModelRequestFields = array_reduce( + array_filter( + $options, + fn ($value, $optionKey) => in_array($optionKey, ['top_k']) + ), + function (array $additionalModelRequestFieldsAcc, string $optionValue, string $optionKey) use (&$options) { + unset($options[$optionKey]); + + $optionKey = implode( + '', + array_map( + fn ($part, $partIndex) => 0 === $partIndex ? mb_lcfirst($part) : mb_ucfirst($part), + explode('_', $optionKey) + ) + ); + + $additionalModelRequestFieldsAcc[$optionKey] = $optionValue; + + return $additionalModelRequestFieldsAcc; + }, + [] + ); + + $signedParameters = $this->requestSigner->signRequest( + method: 'POST', + endpoint: $bedrockEndpoint = sprintf( + 'https://bedrock-runtime.%s.amazonaws.com/model/%s/%s', + $this->region, + $model->getName(), + ($options['stream'] ?? false) ? 'converse-stream' : 'converse' + ), + jsonBody: array_filter( + array_merge($options, [ + 'messages' => $messagesMap, + 'system' => $systemMessagesMap, + 'toolConfig' => $toolConfig, + 'inferenceConfig' => count($inferenceConfig) > 0 ? $inferenceConfig : null, + 'additionalModelRequestFields' => count($additionalModelRequestFields) > 0 ? [ + 'inferenceConfig' => $additionalModelRequestFields, + ] : null, + ]), fn ($cValue) => !is_null($cValue) + ) + ); + + return $this->httpClient->request('POST', $bedrockEndpoint, $signedParameters); + } else { + throw new \Exception('Invalid input, input must be a MessageBag'); + } + } +} diff --git a/src/Bridge/AwsBedrock/Language/ResponseConverter.php b/src/Bridge/AwsBedrock/Language/ResponseConverter.php new file mode 100644 index 00000000..d5b27ddd --- /dev/null +++ b/src/Bridge/AwsBedrock/Language/ResponseConverter.php @@ -0,0 +1,193 @@ +convertStream($response)); + } + + try { + $data = $response->toArray(); + } catch (ClientExceptionInterface $e) { + $data = $response->toArray(throw: false); + + if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) { + throw new ContentFilterException(message: $data['error']['message'], previous: $e); + } + + throw $e; + } + + if (!isset($data['choices'])) { + throw new RuntimeException('Response does not contain choices'); + } + + /** @var Choice[] $choices */ + $choices = \array_map($this->convertChoice(...), $data['choices']); + + if (1 !== count($choices)) { + return new ChoiceResponse(...$choices); + } + + if ($choices[0]->hasToolCall()) { + return new ToolCallResponse(...$choices[0]->getToolCalls()); + } + + return new TextResponse($choices[0]->getContent()); + } + + private function convertStream(HttpResponse $response): \Generator + { + $toolCalls = []; + foreach ((new EventSourceHttpClient())->stream($response) as $chunk) { + if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) { + continue; + } + + try { + $data = $chunk->getArrayData(); + } catch (JsonException) { + // try catch only needed for Symfony 6.4 + continue; + } + + if ($this->streamIsToolCall($data)) { + $toolCalls = $this->convertStreamToToolCalls($toolCalls, $data); + } + + if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) { + yield new ToolCallResponse(...\array_map($this->convertToolCall(...), $toolCalls)); + } + + if (!isset($data['choices'][0]['delta']['content'])) { + continue; + } + + yield $data['choices'][0]['delta']['content']; + } + } + + /** + * @param array $toolCalls + * @param array $data + * + * @return array + */ + private function convertStreamToToolCalls(array $toolCalls, array $data): array + { + if (!isset($data['choices'][0]['delta']['tool_calls'])) { + return $toolCalls; + } + + foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) { + if (isset($toolCall['id'])) { + // initialize tool call + $toolCalls[$i] = [ + 'id' => $toolCall['id'], + 'function' => $toolCall['function'], + ]; + continue; + } + + // add arguments delta to tool call + $toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments']; + } + + return $toolCalls; + } + + /** + * @param array $data + */ + private function streamIsToolCall(array $data): bool + { + return isset($data['choices'][0]['delta']['tool_calls']); + } + + /** + * @param array $data + */ + private function isToolCallsStreamFinished(array $data): bool + { + return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason']; + } + + /** + * @param array{ + * index: integer, + * message: array{ + * role: 'assistant', + * content: ?string, + * tool_calls: array{ + * id: string, + * type: 'function', + * function: array{ + * name: string, + * arguments: string + * }, + * }, + * refusal: ?mixed + * }, + * logprobs: string, + * finish_reason: 'stop'|'length'|'tool_calls'|'content_filter', + * } $choice + */ + private function convertChoice(array $choice): Choice + { + if ('tool_calls' === $choice['finish_reason']) { + return new Choice(toolCalls: \array_map([$this, 'convertToolCall'], $choice['message']['tool_calls'])); + } + + if (in_array($choice['finish_reason'], ['stop', 'length'], true)) { + return new Choice($choice['message']['content']); + } + + throw new RuntimeException(sprintf('Unsupported finish reason "%s".', $choice['finish_reason'])); + } + + /** + * @param array{ + * id: string, + * type: 'function', + * function: array{ + * name: string, + * arguments: string + * } + * } $toolCall + */ + private function convertToolCall(array $toolCall): ToolCall + { + $arguments = json_decode($toolCall['function']['arguments'], true, JSON_THROW_ON_ERROR); + + return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments); + } +} diff --git a/src/Bridge/AwsBedrock/PlatformFactory.php b/src/Bridge/AwsBedrock/PlatformFactory.php new file mode 100644 index 00000000..cd6a088f --- /dev/null +++ b/src/Bridge/AwsBedrock/PlatformFactory.php @@ -0,0 +1,40 @@ + Date: Mon, 19 May 2025 14:52:20 +0100 Subject: [PATCH 02/15] chore: bedrock support --- src/Bridge/AwsBedrock/PlatformFactory.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Bridge/AwsBedrock/PlatformFactory.php b/src/Bridge/AwsBedrock/PlatformFactory.php index cd6a088f..dabbfa6a 100644 --- a/src/Bridge/AwsBedrock/PlatformFactory.php +++ b/src/Bridge/AwsBedrock/PlatformFactory.php @@ -2,10 +2,9 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\OpenAICompatible; +namespace PhpLlm\LlmChain\Bridge\AwsBedrock; use Aws\Credentials\Credentials; -use PhpLlm\LlmChain\Bridge\AwsBedrock\BedrockRequestSigner; use PhpLlm\LlmChain\Bridge\AwsBedrock\Language\ModelClient as LanguageModelClient; use PhpLlm\LlmChain\Bridge\AwsBedrock\Language\ResponseConverter as LanguageResponseConverter; use PhpLlm\LlmChain\Platform; From 4d60d775897b8dce0559144fbadc8cfd7f5f2686 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Mon, 19 May 2025 15:00:01 +0100 Subject: [PATCH 03/15] chore: bedrock support --- src/Bridge/AwsBedrock/AmazonNova.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Bridge/AwsBedrock/AmazonNova.php b/src/Bridge/AwsBedrock/AmazonNova.php index 8f257ae4..1f89d08c 100644 --- a/src/Bridge/AwsBedrock/AmazonNova.php +++ b/src/Bridge/AwsBedrock/AmazonNova.php @@ -2,9 +2,7 @@ declare(strict_types=1); -namespace PhpLlm\LlmChain\Bridge\AwsBedrock\Language; - -use PhpLlm\LlmChain\Bridge\AwsBedrock\BedrockLanguageModel; +namespace PhpLlm\LlmChain\Bridge\AwsBedrock; final readonly class AmazonNova implements BedrockLanguageModel { From de871f92036dafdb51303342e3866600f137e537 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Mon, 19 May 2025 15:38:03 +0100 Subject: [PATCH 04/15] chore: bedrock image and text support --- src/Bridge/AwsBedrock/AmazonNova.php | 10 +-- .../AwsBedrock/Language/ModelClient.php | 45 ++++++++--- .../AwsBedrock/Language/ResponseConverter.php | 80 ++++++++----------- 3 files changed, 72 insertions(+), 63 deletions(-) diff --git a/src/Bridge/AwsBedrock/AmazonNova.php b/src/Bridge/AwsBedrock/AmazonNova.php index 1f89d08c..4694039a 100644 --- a/src/Bridge/AwsBedrock/AmazonNova.php +++ b/src/Bridge/AwsBedrock/AmazonNova.php @@ -39,26 +39,26 @@ public function getOptions(): array public function supportsAudioInput(): bool { - return false; // it does, but implementation here is still open. + return false; // Only videos (!?) } public function supportsImageInput(): bool { - return false; // it does, but implementation here is still open. + return true; } public function supportsStreaming(): bool { - return false; // it does, but implementation here is still open. + return true; } public function supportsToolCalling(): bool { - return false; // it does, but implementation here is still open. + return true; } public function supportsStructuredOutput(): bool { - return false; // it does, but implementation here is still open. + return false; } } diff --git a/src/Bridge/AwsBedrock/Language/ModelClient.php b/src/Bridge/AwsBedrock/Language/ModelClient.php index ba339e78..2d5c69a9 100644 --- a/src/Bridge/AwsBedrock/Language/ModelClient.php +++ b/src/Bridge/AwsBedrock/Language/ModelClient.php @@ -8,10 +8,13 @@ use PhpLlm\LlmChain\Bridge\AwsBedrock\BedrockRequestSigner; use PhpLlm\LlmChain\Chain\Toolbox\Metadata; use PhpLlm\LlmChain\Model\Message\AssistantMessage; +use PhpLlm\LlmChain\Model\Message\Content\Content; +use PhpLlm\LlmChain\Model\Message\Content\Image; +use PhpLlm\LlmChain\Model\Message\Content\Text; use PhpLlm\LlmChain\Model\Message\MessageBag; use PhpLlm\LlmChain\Model\Message\MessageInterface; -use PhpLlm\LlmChain\Model\Message\Role; use PhpLlm\LlmChain\Model\Message\ToolCallMessage; +use PhpLlm\LlmChain\Model\Message\UserMessage; use PhpLlm\LlmChain\Model\Model; use PhpLlm\LlmChain\Platform\ModelClient as PlatformResponseFactory; use Symfony\Component\HttpClient\EventSourceHttpClient; @@ -81,19 +84,35 @@ function (MessageInterface $inputEntry) { ), ], ]; + } elseif ($inputEntry instanceof UserMessage) { + return [ + 'role' => 'user', + 'content' => array_map( + function (Content $inputContent) { + if ($inputContent instanceof Text) { + return [ + 'text' => $inputContent->text, + ]; + } elseif ($inputContent instanceof Image) { + return [ + 'image' => [ + 'format' => match ($inputContent->getFormat()) { + 'image/png' => 'png', + 'image/jpg', 'image/jpeg' => 'jpeg', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + default => throw new \Exception('Invalid Image type') + }, + 'source' => [ + 'bytes' => $inputContent->asBase64(), + ], + ], + ]; + } + }, $inputEntry->content + ), + ]; } - - return [ - 'role' => match ($inputEntry->getRole()) { - Role::Assistant => 'assistant', - default => 'user' - }, - 'content' => [ - [ - 'text' => $inputEntry->jsonSerialize()['content'] ?? null, - ], - ], - ]; }, $input->withoutSystemMessage()->getMessages() ); diff --git a/src/Bridge/AwsBedrock/Language/ResponseConverter.php b/src/Bridge/AwsBedrock/Language/ResponseConverter.php index d5b27ddd..ca6e99b5 100644 --- a/src/Bridge/AwsBedrock/Language/ResponseConverter.php +++ b/src/Bridge/AwsBedrock/Language/ResponseConverter.php @@ -38,21 +38,37 @@ public function convert(HttpResponse $response, array $options = []): LlmRespons try { $data = $response->toArray(); } catch (ClientExceptionInterface $e) { - $data = $response->toArray(throw: false); - - if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) { - throw new ContentFilterException(message: $data['error']['message'], previous: $e); + if (400 === $response->getStatusCode()) { + throw new ContentFilterException(message: 'Validation error', previous: $e); } throw $e; } - if (!isset($data['choices'])) { + if (!isset($data['output']['message']['content'])) { throw new RuntimeException('Response does not contain choices'); } + $stopReason = $data['stopReason']; + /** @var Choice[] $choices */ - $choices = \array_map($this->convertChoice(...), $data['choices']); + $choices = array_values( + array_filter( + array_map( + fn ($content) => $this->convertChoice( + $content, $stopReason + ), + $data['output']['message']['content'] + ), + function (Choice $choiceEntry) use (&$stopReason) { + if ('tool_use' === $stopReason) { + return $choiceEntry->hasToolCall(); + } + + return true; + } + ) + ); if (1 !== count($choices)) { return new ChoiceResponse(...$choices); @@ -141,53 +157,27 @@ private function isToolCallsStreamFinished(array $data): bool return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason']; } - /** - * @param array{ - * index: integer, - * message: array{ - * role: 'assistant', - * content: ?string, - * tool_calls: array{ - * id: string, - * type: 'function', - * function: array{ - * name: string, - * arguments: string - * }, - * }, - * refusal: ?mixed - * }, - * logprobs: string, - * finish_reason: 'stop'|'length'|'tool_calls'|'content_filter', - * } $choice - */ - private function convertChoice(array $choice): Choice + private function convertChoice(array $choice, string $stopReason): Choice { - if ('tool_calls' === $choice['finish_reason']) { - return new Choice(toolCalls: \array_map([$this, 'convertToolCall'], $choice['message']['tool_calls'])); + if (isset($choice['toolUse'])) { + return new Choice( + toolCalls: [ + $this->convertToolCall($choice['toolUse']), + ] + ); } - if (in_array($choice['finish_reason'], ['stop', 'length'], true)) { - return new Choice($choice['message']['content']); + if (isset($choice['text'])) { + return new Choice( + $choice['text'] + ); } - throw new RuntimeException(sprintf('Unsupported finish reason "%s".', $choice['finish_reason'])); + throw new RuntimeException(sprintf('Unsupported finish reason "%s".', $stopReason)); } - /** - * @param array{ - * id: string, - * type: 'function', - * function: array{ - * name: string, - * arguments: string - * } - * } $toolCall - */ private function convertToolCall(array $toolCall): ToolCall { - $arguments = json_decode($toolCall['function']['arguments'], true, JSON_THROW_ON_ERROR); - - return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments); + return new ToolCall($toolCall['toolUseId'], $toolCall['name'], $toolCall['input']); } } From 94840671b059824b819b58ac718be70686b50da5 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Mon, 19 May 2025 15:47:26 +0100 Subject: [PATCH 05/15] chore: bedrock tokenoutput processor --- .../AwsBedrock/TokenOutputProcessor.php | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/Bridge/AwsBedrock/TokenOutputProcessor.php diff --git a/src/Bridge/AwsBedrock/TokenOutputProcessor.php b/src/Bridge/AwsBedrock/TokenOutputProcessor.php new file mode 100644 index 00000000..2e39f72f --- /dev/null +++ b/src/Bridge/AwsBedrock/TokenOutputProcessor.php @@ -0,0 +1,42 @@ +response instanceof StreamResponse) { + // Streams have to be handled manually as the tokens are part of the streamed chunks + return; + } + + $rawResponse = $output->response->getRawResponse(); + if (null === $rawResponse) { + return; + } + + $metadata = $output->response->getMetadata(); + + $metadata->add( + 'remaining_tokens', + (int) $rawResponse->getHeaders(false)['x-ratelimit-remaining-tokens'][0], + ); + + $content = $rawResponse->toArray(false); + + if (!\array_key_exists('usage', $content)) { + return; + } + + $metadata->add('prompt_tokens', $content['usage']['prompt_tokens'] ?? null); + $metadata->add('completion_tokens', $content['usage']['completion_tokens'] ?? null); + $metadata->add('total_tokens', $content['usage']['total_tokens'] ?? null); + } +} From 5ba9d25e7f05686de3dd7b50331fc38f3a3ddf88 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Mon, 19 May 2025 16:56:10 +0100 Subject: [PATCH 06/15] chore: bedrock final --- src/Bridge/AwsBedrock/AmazonNova.php | 2 +- .../AwsBedrock/Language/ResponseConverter.php | 84 ------------------- .../AwsBedrock/TokenOutputProcessor.php | 11 +-- 3 files changed, 4 insertions(+), 93 deletions(-) diff --git a/src/Bridge/AwsBedrock/AmazonNova.php b/src/Bridge/AwsBedrock/AmazonNova.php index 4694039a..3544d561 100644 --- a/src/Bridge/AwsBedrock/AmazonNova.php +++ b/src/Bridge/AwsBedrock/AmazonNova.php @@ -49,7 +49,7 @@ public function supportsImageInput(): bool public function supportsStreaming(): bool { - return true; + return false; // It does, but it's not implemented yet. } public function supportsToolCalling(): bool diff --git a/src/Bridge/AwsBedrock/Language/ResponseConverter.php b/src/Bridge/AwsBedrock/Language/ResponseConverter.php index ca6e99b5..f4019bc3 100644 --- a/src/Bridge/AwsBedrock/Language/ResponseConverter.php +++ b/src/Bridge/AwsBedrock/Language/ResponseConverter.php @@ -11,14 +11,10 @@ use PhpLlm\LlmChain\Model\Response\Choice; use PhpLlm\LlmChain\Model\Response\ChoiceResponse; use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse; -use PhpLlm\LlmChain\Model\Response\StreamResponse; use PhpLlm\LlmChain\Model\Response\TextResponse; use PhpLlm\LlmChain\Model\Response\ToolCall; use PhpLlm\LlmChain\Model\Response\ToolCallResponse; use PhpLlm\LlmChain\Platform\ResponseConverter as PlatformResponseConverter; -use Symfony\Component\HttpClient\Chunk\ServerSentEvent; -use Symfony\Component\HttpClient\EventSourceHttpClient; -use Symfony\Component\HttpClient\Exception\JsonException; use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; @@ -31,10 +27,6 @@ public function supports(Model $model, array|string|object $input): bool public function convert(HttpResponse $response, array $options = []): LlmResponse { - if ($options['stream'] ?? false) { - return new StreamResponse($this->convertStream($response)); - } - try { $data = $response->toArray(); } catch (ClientExceptionInterface $e) { @@ -81,82 +73,6 @@ function (Choice $choiceEntry) use (&$stopReason) { return new TextResponse($choices[0]->getContent()); } - private function convertStream(HttpResponse $response): \Generator - { - $toolCalls = []; - foreach ((new EventSourceHttpClient())->stream($response) as $chunk) { - if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) { - continue; - } - - try { - $data = $chunk->getArrayData(); - } catch (JsonException) { - // try catch only needed for Symfony 6.4 - continue; - } - - if ($this->streamIsToolCall($data)) { - $toolCalls = $this->convertStreamToToolCalls($toolCalls, $data); - } - - if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) { - yield new ToolCallResponse(...\array_map($this->convertToolCall(...), $toolCalls)); - } - - if (!isset($data['choices'][0]['delta']['content'])) { - continue; - } - - yield $data['choices'][0]['delta']['content']; - } - } - - /** - * @param array $toolCalls - * @param array $data - * - * @return array - */ - private function convertStreamToToolCalls(array $toolCalls, array $data): array - { - if (!isset($data['choices'][0]['delta']['tool_calls'])) { - return $toolCalls; - } - - foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) { - if (isset($toolCall['id'])) { - // initialize tool call - $toolCalls[$i] = [ - 'id' => $toolCall['id'], - 'function' => $toolCall['function'], - ]; - continue; - } - - // add arguments delta to tool call - $toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments']; - } - - return $toolCalls; - } - - /** - * @param array $data - */ - private function streamIsToolCall(array $data): bool - { - return isset($data['choices'][0]['delta']['tool_calls']); - } - - /** - * @param array $data - */ - private function isToolCallsStreamFinished(array $data): bool - { - return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason']; - } - private function convertChoice(array $choice, string $stopReason): Choice { if (isset($choice['toolUse'])) { diff --git a/src/Bridge/AwsBedrock/TokenOutputProcessor.php b/src/Bridge/AwsBedrock/TokenOutputProcessor.php index 2e39f72f..789bfac0 100644 --- a/src/Bridge/AwsBedrock/TokenOutputProcessor.php +++ b/src/Bridge/AwsBedrock/TokenOutputProcessor.php @@ -24,19 +24,14 @@ public function processOutput(Output $output): void $metadata = $output->response->getMetadata(); - $metadata->add( - 'remaining_tokens', - (int) $rawResponse->getHeaders(false)['x-ratelimit-remaining-tokens'][0], - ); - $content = $rawResponse->toArray(false); if (!\array_key_exists('usage', $content)) { return; } - $metadata->add('prompt_tokens', $content['usage']['prompt_tokens'] ?? null); - $metadata->add('completion_tokens', $content['usage']['completion_tokens'] ?? null); - $metadata->add('total_tokens', $content['usage']['total_tokens'] ?? null); + $metadata->add('prompt_tokens', $content['usage']['inputTokens'] ?? null); + $metadata->add('completion_tokens', $content['usage']['outputTokens'] ?? null); + $metadata->add('total_tokens', $content['usage']['totalTokens'] ?? null); } } From 755452ae9bea3a502e7ca88d21cfe02830d204e5 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Mon, 19 May 2025 16:59:19 +0100 Subject: [PATCH 07/15] chore: bedrock array_filter options --- src/Bridge/AwsBedrock/Language/ModelClient.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Bridge/AwsBedrock/Language/ModelClient.php b/src/Bridge/AwsBedrock/Language/ModelClient.php index 2d5c69a9..ba3fb5bd 100644 --- a/src/Bridge/AwsBedrock/Language/ModelClient.php +++ b/src/Bridge/AwsBedrock/Language/ModelClient.php @@ -139,7 +139,8 @@ function (Content $inputContent) { $inferenceConfig = array_reduce( array_filter( $options, - fn ($value, $optionKey) => in_array($optionKey, ['max_tokens', 'stop_sequences', 'temperature', 'top_p']) + fn ($value, $optionKey) => in_array($optionKey, ['max_tokens', 'stop_sequences', 'temperature', 'top_p']), + ARRAY_FILTER_USE_BOTH ), function (array $inferenceConfigAcc, string $optionValue, string $optionKey) use (&$options) { unset($options[$optionKey]); @@ -162,7 +163,8 @@ function (array $inferenceConfigAcc, string $optionValue, string $optionKey) use $additionalModelRequestFields = array_reduce( array_filter( $options, - fn ($value, $optionKey) => in_array($optionKey, ['top_k']) + fn ($value, $optionKey) => in_array($optionKey, ['top_k']), + ARRAY_FILTER_USE_BOTH ), function (array $additionalModelRequestFieldsAcc, string $optionValue, string $optionKey) use (&$options) { unset($options[$optionKey]); From 51b72743cafeb44da12cc24c9a2fbb71d29bd639 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Mon, 19 May 2025 17:01:37 +0100 Subject: [PATCH 08/15] chore: cerebrasAI and openai compatible language model with response converter --- src/Bridge/CerebrasAI/CerebrasAI.php | 60 +++++++++++++++++++++ src/Bridge/CerebrasAI/Client.php | 44 +++++++++++++++ src/Bridge/CerebrasAI/PlatformFactory.php | 27 ++++++++++ src/Bridge/OpenAI/GPT.php | 4 +- src/Bridge/OpenAI/GPT/ResponseConverter.php | 4 +- src/Model/OpenAiCompatibleLanguageModel.php | 7 +++ 6 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 src/Bridge/CerebrasAI/CerebrasAI.php create mode 100644 src/Bridge/CerebrasAI/Client.php create mode 100644 src/Bridge/CerebrasAI/PlatformFactory.php create mode 100644 src/Model/OpenAiCompatibleLanguageModel.php diff --git a/src/Bridge/CerebrasAI/CerebrasAI.php b/src/Bridge/CerebrasAI/CerebrasAI.php new file mode 100644 index 00000000..4af8b8d2 --- /dev/null +++ b/src/Bridge/CerebrasAI/CerebrasAI.php @@ -0,0 +1,60 @@ + $options + */ + public function __construct( + private string $name = self::LLAMA_31_8, + private array $options = [], + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function getOptions(): array + { + return $this->options; + } + + public function supportsAudioInput(): bool + { + return false; + } + + public function supportsImageInput(): bool + { + return false; + } + + public function supportsStreaming(): bool + { + return true; + } + + public function supportsToolCalling(): bool + { + return true; + } + + public function supportsStructuredOutput(): bool + { + return true; + } +} diff --git a/src/Bridge/CerebrasAI/Client.php b/src/Bridge/CerebrasAI/Client.php new file mode 100644 index 00000000..2108cb1e --- /dev/null +++ b/src/Bridge/CerebrasAI/Client.php @@ -0,0 +1,44 @@ +httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + Assert::stringNotEmpty($apiKey, 'The API key must not be empty.'); + Assert::startsWith($apiKey, 'csk-', 'The API key must start with "csk-".'); + } + + public function supports(Model $model, array|string|object $input): bool + { + return $input instanceof MessageBagInterface && $model instanceof CerebrasAI; + } + + public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface + { + return $this->httpClient->request('POST', 'https://api.cerebras.ai/v1/chat/completions', [ + 'auth_bearer' => $this->apiKey, + 'json' => array_merge($options, [ + 'model' => $model->getName(), + 'messages' => $input, + ]), + ]); + } +} diff --git a/src/Bridge/CerebrasAI/PlatformFactory.php b/src/Bridge/CerebrasAI/PlatformFactory.php new file mode 100644 index 00000000..98a85626 --- /dev/null +++ b/src/Bridge/CerebrasAI/PlatformFactory.php @@ -0,0 +1,27 @@ + Date: Mon, 19 May 2025 19:56:06 +0100 Subject: [PATCH 09/15] chore: gemini function calling, toolbox, image, video, and file support --- examples/google/toolcall.php | 33 +++++++++ src/Bridge/Google/Gemini.php | 4 +- src/Bridge/Google/GooglePromptConverter.php | 38 ++++++++++- src/Bridge/Google/ModelHandler.php | 76 ++++++++++++++++++++- 4 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 examples/google/toolcall.php diff --git a/examples/google/toolcall.php b/examples/google/toolcall.php new file mode 100644 index 00000000..cfdd7708 --- /dev/null +++ b/examples/google/toolcall.php @@ -0,0 +1,33 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['GOOGLE_API_KEY'])) { + echo 'Please set the GOOGLE_API_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['GOOGLE_API_KEY']); +$llm = new Gemini(Gemini::GEMINI_2_FLASH); + +$transcriber = new YouTubeTranscriber(HttpClient::create()); +$toolbox = Toolbox::create($transcriber); +$processor = new ChainProcessor($toolbox); +$chain = new Chain($platform, $llm, [$processor], [$processor]); + +$messages = new MessageBag(Message::ofUser('Please summarize this video for me https://www.youtube.com/watch?v=6uXW-ulpj0s with the video ID as 6uXW-ulpj0s')); +$response = $chain->call($messages); + +echo $response->getContent().PHP_EOL; diff --git a/src/Bridge/Google/Gemini.php b/src/Bridge/Google/Gemini.php index e8a82d5b..c428fe79 100644 --- a/src/Bridge/Google/Gemini.php +++ b/src/Bridge/Google/Gemini.php @@ -35,7 +35,7 @@ public function getOptions(): array public function supportsAudioInput(): bool { - return false; // it does, but implementation here is still open + return true; } public function supportsImageInput(): bool @@ -55,6 +55,6 @@ public function supportsStructuredOutput(): bool public function supportsToolCalling(): bool { - return false; // it does, but implementation here is still open + return true; } } diff --git a/src/Bridge/Google/GooglePromptConverter.php b/src/Bridge/Google/GooglePromptConverter.php index 8951a3a3..1995e818 100644 --- a/src/Bridge/Google/GooglePromptConverter.php +++ b/src/Bridge/Google/GooglePromptConverter.php @@ -5,11 +5,14 @@ namespace PhpLlm\LlmChain\Bridge\Google; use PhpLlm\LlmChain\Model\Message\AssistantMessage; +use PhpLlm\LlmChain\Model\Message\Content\Audio; +use PhpLlm\LlmChain\Model\Message\Content\File; use PhpLlm\LlmChain\Model\Message\Content\Image; use PhpLlm\LlmChain\Model\Message\Content\Text; use PhpLlm\LlmChain\Model\Message\MessageBagInterface; use PhpLlm\LlmChain\Model\Message\MessageInterface; use PhpLlm\LlmChain\Model\Message\Role; +use PhpLlm\LlmChain\Model\Message\ToolCallMessage; use PhpLlm\LlmChain\Model\Message\UserMessage; final class GooglePromptConverter @@ -50,7 +53,38 @@ public function convertToPrompt(MessageBagInterface $bag): array private function convertMessage(MessageInterface $message): array { if ($message instanceof AssistantMessage) { - return [['text' => $message->content]]; + return [ + array_filter( + [ + 'text' => $message->content, + 'functionCall' => ($message->toolCalls[0] ?? null) ? [ + 'id' => $message->toolCalls[0]->id, + 'name' => $message->toolCalls[0]->name, + 'args' => $message->toolCalls[0]->arguments, + ] : null, + ], + fn ($content) => !is_null($content) + ), + ]; + } + + if ($message instanceof ToolCallMessage) { + $responseContent = json_validate($message->content) ? + json_decode($message->content, true) : $message->content; + + return [ + [ + 'functionResponse' => array_filter( + [ + 'id' => $message->toolCall->id, + 'name' => $message->toolCall->name, + 'response' => is_array($responseContent) ? $responseContent : [ + 'rawResponse' => $responseContent, // Gemini expects the response to be an object, but not everyone uses objects as their responses. + ], + ], fn ($value) => $value + ), + ], + ]; } if ($message instanceof UserMessage) { @@ -59,7 +93,7 @@ private function convertMessage(MessageInterface $message): array if ($content instanceof Text) { $parts[] = ['text' => $content->text]; } - if ($content instanceof Image) { + if ($content instanceof Image || $content instanceof Audio || $content instanceof File) { $parts[] = ['inline_data' => [ 'mime_type' => $content->getFormat(), 'data' => $content->asBase64(), diff --git a/src/Bridge/Google/ModelHandler.php b/src/Bridge/Google/ModelHandler.php index f3875c25..30d53034 100644 --- a/src/Bridge/Google/ModelHandler.php +++ b/src/Bridge/Google/ModelHandler.php @@ -4,12 +4,17 @@ namespace PhpLlm\LlmChain\Bridge\Google; +use PhpLlm\LlmChain\Chain\Toolbox\Metadata; use PhpLlm\LlmChain\Exception\RuntimeException; use PhpLlm\LlmChain\Model\Message\MessageBagInterface; use PhpLlm\LlmChain\Model\Model; +use PhpLlm\LlmChain\Model\Response\Choice; +use PhpLlm\LlmChain\Model\Response\ChoiceResponse; use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse; use PhpLlm\LlmChain\Model\Response\StreamResponse; use PhpLlm\LlmChain\Model\Response\TextResponse; +use PhpLlm\LlmChain\Model\Response\ToolCall; +use PhpLlm\LlmChain\Model\Response\ToolCallResponse; use PhpLlm\LlmChain\Platform\ModelClient; use PhpLlm\LlmChain\Platform\ResponseConverter; use Symfony\Component\HttpClient\EventSourceHttpClient; @@ -54,6 +59,29 @@ public function request(Model $model, object|array|string $input, array $options $generationConfig = ['generationConfig' => $options]; unset($generationConfig['generationConfig']['stream']); + unset($generationConfig['generationConfig']['tools']); + + if (isset($options['tools'])) { + $toolConfig = array_map( + function (Metadata $toolConfig) { + $parameters = $toolConfig->parameters; + unset($parameters['additionalProperties']); + + return [ + 'functionDeclarations' => [ + [ + 'description' => $toolConfig->description, + 'name' => $toolConfig->name, + 'parameters' => $parameters, + ], + ], + ]; + }, $options['tools'] + ); + + unset($options['tools']); + $generationConfig['tools'] = $toolConfig; + } return $this->httpClient->request('POST', $url, [ 'headers' => [ @@ -78,11 +106,27 @@ public function convert(ResponseInterface $response, array $options = []): LlmRe $data = $response->toArray(); - if (!isset($data['candidates'][0]['content']['parts'][0]['text'])) { + if (!isset($data['candidates'][0]['content']['parts'][0])) { throw new RuntimeException('Response does not contain any content'); } - return new TextResponse($data['candidates'][0]['content']['parts'][0]['text']); + /** @var Choice[] $choices */ + $choices = array_map( + fn ($content) => $this->convertChoice( + $content + ), + $data['candidates'] + ); + + if (1 !== count($choices)) { + return new ChoiceResponse(...$choices); + } + + if ($choices[0]->hasToolCall()) { + return new ToolCallResponse(...$choices[0]->getToolCalls()); + } + + return new TextResponse($choices[0]->getContent()); } private function convertStream(ResponseInterface $response): \Generator @@ -125,4 +169,32 @@ private function convertStream(ResponseInterface $response): \Generator } } } + + private function convertChoice(array $choice): Choice + { + $stopReason = $choice['finishReason']; + + $contentPart = $choice['content']['parts'][0] ?? []; + + if (isset($contentPart['functionCall'])) { + return new Choice( + toolCalls: [ + $this->convertToolCall($contentPart['functionCall']), + ] + ); + } + + if (isset($contentPart['text'])) { + return new Choice( + $contentPart['text'] + ); + } + + throw new RuntimeException(sprintf('Unsupported finish reason "%s".', $stopReason)); + } + + private function convertToolCall(array $toolCall): ToolCall + { + return new ToolCall($toolCall['id'] ?? '', $toolCall['name'], $toolCall['args']); + } } From 03ef18757ca0a4e104642059af72a69ec66be56a Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Mon, 19 May 2025 20:47:39 +0100 Subject: [PATCH 10/15] chore: add openai compatible platform --- src/Bridge/OpenAICompatible/Client.php | 47 +++++++++++++++ src/Bridge/OpenAICompatible/GenericModel.php | 58 +++++++++++++++++++ .../OpenAICompatible/PlatformFactory.php | 28 +++++++++ 3 files changed, 133 insertions(+) create mode 100644 src/Bridge/OpenAICompatible/Client.php create mode 100644 src/Bridge/OpenAICompatible/GenericModel.php create mode 100644 src/Bridge/OpenAICompatible/PlatformFactory.php diff --git a/src/Bridge/OpenAICompatible/Client.php b/src/Bridge/OpenAICompatible/Client.php new file mode 100644 index 00000000..199b373f --- /dev/null +++ b/src/Bridge/OpenAICompatible/Client.php @@ -0,0 +1,47 @@ +httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + Assert::stringNotEmpty($apiKey, 'The API key must not be empty.'); + + Assert::stringNotEmpty($baseUrl, 'Base URL must not be empty.'); + Assert::startsWith($baseUrl, 'http', 'Base URL must have a valid protocol.'); + } + + public function supports(Model $model, array|string|object $input): bool + { + return $input instanceof MessageBagInterface && $model instanceof GenericModel; + } + + public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface + { + return $this->httpClient->request('POST', sprintf('%s/chat/completions', $this->baseUrl), [ + 'auth_bearer' => $this->apiKey, + 'json' => array_merge($options, [ + 'model' => $model->getName(), + 'messages' => $input, + ]), + ]); + } +} diff --git a/src/Bridge/OpenAICompatible/GenericModel.php b/src/Bridge/OpenAICompatible/GenericModel.php new file mode 100644 index 00000000..afbea997 --- /dev/null +++ b/src/Bridge/OpenAICompatible/GenericModel.php @@ -0,0 +1,58 @@ + $options + */ + public function __construct( + private string $name, + private array $options = [], + private bool $supportsAudioInput = false, + private bool $supportsImageInput = false, + private bool $supportsToolCalling = false, + private bool $supportsStructuredOutput = false, + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function getOptions(): array + { + return $this->options; + } + + public function supportsAudioInput(): bool + { + return $this->supportsAudioInput; + } + + public function supportsImageInput(): bool + { + return $this->supportsImageInput; + } + + public function supportsStreaming(): bool + { + return true; + } + + public function supportsToolCalling(): bool + { + return $this->supportsToolCalling; + } + + public function supportsStructuredOutput(): bool + { + return $this->supportsStructuredOutput; + } +} diff --git a/src/Bridge/OpenAICompatible/PlatformFactory.php b/src/Bridge/OpenAICompatible/PlatformFactory.php new file mode 100644 index 00000000..bb3e679a --- /dev/null +++ b/src/Bridge/OpenAICompatible/PlatformFactory.php @@ -0,0 +1,28 @@ + Date: Mon, 19 May 2025 21:07:13 +0100 Subject: [PATCH 11/15] chore: chain iteractions event for chainprocessor --- src/Chain/Toolbox/ChainProcessor.php | 5 +++++ .../Toolbox/Event/ChainIteractionsFinished.php | 14 ++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 src/Chain/Toolbox/Event/ChainIteractionsFinished.php diff --git a/src/Chain/Toolbox/ChainProcessor.php b/src/Chain/Toolbox/ChainProcessor.php index 2e58c113..aceae8ac 100644 --- a/src/Chain/Toolbox/ChainProcessor.php +++ b/src/Chain/Toolbox/ChainProcessor.php @@ -10,6 +10,7 @@ use PhpLlm\LlmChain\Chain\InputProcessor; use PhpLlm\LlmChain\Chain\Output; use PhpLlm\LlmChain\Chain\OutputProcessor; +use PhpLlm\LlmChain\Chain\Toolbox\Event\ChainIteractionsFinished; use PhpLlm\LlmChain\Chain\Toolbox\Event\ToolCallsExecuted; use PhpLlm\LlmChain\Chain\Toolbox\StreamResponse as ToolboxStreamResponse; use PhpLlm\LlmChain\Exception\MissingModelSupport; @@ -104,6 +105,10 @@ private function handleToolCallsCallback(Output $output): \Closure $response = $event->hasResponse() ? $event->response : $this->chain->call($messages, $output->options); } while ($response instanceof ToolCallResponse); + $this->eventDispatcher?->dispatch( + new ChainIteractionsFinished($messages) + ); + return $response; }; } diff --git a/src/Chain/Toolbox/Event/ChainIteractionsFinished.php b/src/Chain/Toolbox/Event/ChainIteractionsFinished.php new file mode 100644 index 00000000..f1ca6f4c --- /dev/null +++ b/src/Chain/Toolbox/Event/ChainIteractionsFinished.php @@ -0,0 +1,14 @@ + Date: Mon, 19 May 2025 22:06:33 +0100 Subject: [PATCH 12/15] chore: bedrock embeddings --- .env | 7 +++ examples/awsbedrock/embeddings.php | 34 ++++++++++++++ src/Bridge/AwsBedrock/Embeddings.php | 37 +++++++++++++++ .../AwsBedrock/Embeddings/ModelClient.php | 46 +++++++++++++++++++ .../Embeddings/ResponseConverter.php | 34 ++++++++++++++ .../AwsBedrock/Language/ModelClient.php | 3 ++ src/Bridge/AwsBedrock/PlatformFactory.php | 4 ++ 7 files changed, 165 insertions(+) create mode 100644 examples/awsbedrock/embeddings.php create mode 100644 src/Bridge/AwsBedrock/Embeddings.php create mode 100644 src/Bridge/AwsBedrock/Embeddings/ModelClient.php create mode 100644 src/Bridge/AwsBedrock/Embeddings/ResponseConverter.php diff --git a/.env b/.env index b4cb8cbf..d180a0ff 100644 --- a/.env +++ b/.env @@ -56,3 +56,10 @@ RUN_EXPENSIVE_EXAMPLES=false # For using Gemini GOOGLE_API_KEY= + +# For using Aws BedRock + +AWS_ACCESS_KEY= +AWS_ACCESS_SECRET= +AWS_REGION= +AWS_INFERENCE_PROFILE_REGION= \ No newline at end of file diff --git a/examples/awsbedrock/embeddings.php b/examples/awsbedrock/embeddings.php new file mode 100644 index 00000000..4cf23a4f --- /dev/null +++ b/examples/awsbedrock/embeddings.php @@ -0,0 +1,34 @@ +loadEnv(dirname(__DIR__, 2).'/.env'); + +if (empty($_ENV['AWS_ACCESS_KEY']) || empty($_ENV['AWS_ACCESS_SECRET']) || empty($_ENV['AWS_REGION'])) { + echo 'Please set the AWS_ACCESS_KEY, AWS_ACCESS_SECRET, AWS_REGION environment variables.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create( + [ + 'key' => $_ENV['AWS_ACCESS_KEY'], + 'secret' => $_ENV['AWS_ACCESS_SECRET'], + ], + $_ENV['AWS_REGION'], +); + +$embeddings = new Embeddings(); + +$response = $platform->request($embeddings, <<getContent()[0] instanceof Vector); + +echo 'Dimensions: '.$response->getContent()[0]->getDimensions().PHP_EOL; diff --git a/src/Bridge/AwsBedrock/Embeddings.php b/src/Bridge/AwsBedrock/Embeddings.php new file mode 100644 index 00000000..02219b78 --- /dev/null +++ b/src/Bridge/AwsBedrock/Embeddings.php @@ -0,0 +1,37 @@ +inferenceProfileRegion) { + return $this->inferenceProfileRegion.'.'.$this->name; + } + + return $this->name; + } + + public function getOptions(): array + { + return $this->options; + } +} diff --git a/src/Bridge/AwsBedrock/Embeddings/ModelClient.php b/src/Bridge/AwsBedrock/Embeddings/ModelClient.php new file mode 100644 index 00000000..cc326188 --- /dev/null +++ b/src/Bridge/AwsBedrock/Embeddings/ModelClient.php @@ -0,0 +1,46 @@ +requestSigner->signRequest( + method: 'POST', + endpoint: $bedrockEndpoint = sprintf( + 'https://bedrock-runtime.%s.amazonaws.com/model/%s/invoke', + $this->region, + $model->getName(), + ), + jsonBody: is_string($input) ? [ + 'inputText' => $input, + ] : $input + ); + + return $this->httpClient->request('POST', $bedrockEndpoint, $signedParameters); + } +} diff --git a/src/Bridge/AwsBedrock/Embeddings/ResponseConverter.php b/src/Bridge/AwsBedrock/Embeddings/ResponseConverter.php new file mode 100644 index 00000000..a232d8a6 --- /dev/null +++ b/src/Bridge/AwsBedrock/Embeddings/ResponseConverter.php @@ -0,0 +1,34 @@ +toArray(); + + if (!isset($data['embedding'])) { + throw new RuntimeException('Response does not contain data'); + } + + return new VectorResponse( + new Vector($data['embedding']) + ); + } +} diff --git a/src/Bridge/AwsBedrock/Language/ModelClient.php b/src/Bridge/AwsBedrock/Language/ModelClient.php index ba3fb5bd..62b85e71 100644 --- a/src/Bridge/AwsBedrock/Language/ModelClient.php +++ b/src/Bridge/AwsBedrock/Language/ModelClient.php @@ -20,6 +20,7 @@ use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\ResponseInterface; +use Webmozart\Assert\Assert; final readonly class ModelClient implements PlatformResponseFactory { @@ -31,6 +32,8 @@ public function __construct( private string $region, ) { $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + Assert::stringNotEmpty($region, 'The region must not be empty.'); } public function supports(Model $model, array|string|object $input): bool diff --git a/src/Bridge/AwsBedrock/PlatformFactory.php b/src/Bridge/AwsBedrock/PlatformFactory.php index dabbfa6a..975f492e 100644 --- a/src/Bridge/AwsBedrock/PlatformFactory.php +++ b/src/Bridge/AwsBedrock/PlatformFactory.php @@ -5,6 +5,8 @@ namespace PhpLlm\LlmChain\Bridge\AwsBedrock; use Aws\Credentials\Credentials; +use PhpLlm\LlmChain\Bridge\AwsBedrock\Embeddings\ModelClient as EmbeddingsModelClient; +use PhpLlm\LlmChain\Bridge\AwsBedrock\Embeddings\ResponseConverter as EmbeddingsResponseConverter; use PhpLlm\LlmChain\Bridge\AwsBedrock\Language\ModelClient as LanguageModelClient; use PhpLlm\LlmChain\Bridge\AwsBedrock\Language\ResponseConverter as LanguageResponseConverter; use PhpLlm\LlmChain\Platform; @@ -32,8 +34,10 @@ public static function create( return new Platform([ new LanguageModelClient($httpClient, $requesterSigner, $region), + new EmbeddingsModelClient($httpClient, $requesterSigner, $region), ], [ new LanguageResponseConverter(), + new EmbeddingsResponseConverter(), ]); } } From e70d4a128ac6d342a2808689831c1753ca5e763d Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Wed, 21 May 2025 01:00:30 +0100 Subject: [PATCH 13/15] chore: model options --- .../AwsBedrock/Embeddings/ModelClient.php | 11 ++-- .../AwsBedrock/Language/ModelClient.php | 54 +++++++++++-------- 2 files changed, 39 insertions(+), 26 deletions(-) diff --git a/src/Bridge/AwsBedrock/Embeddings/ModelClient.php b/src/Bridge/AwsBedrock/Embeddings/ModelClient.php index cc326188..a4d1ef85 100644 --- a/src/Bridge/AwsBedrock/Embeddings/ModelClient.php +++ b/src/Bridge/AwsBedrock/Embeddings/ModelClient.php @@ -36,9 +36,14 @@ public function request(Model $model, object|array|string $input, array $options $this->region, $model->getName(), ), - jsonBody: is_string($input) ? [ - 'inputText' => $input, - ] : $input + jsonBody: is_string($input) ? array_merge( + $model->getOptions(), [ + 'inputText' => $input, + ] + ) : ( + is_array($input) ? + array_merge($model->getOptions(), $options, $input) : $input + ) ); return $this->httpClient->request('POST', $bedrockEndpoint, $signedParameters); diff --git a/src/Bridge/AwsBedrock/Language/ModelClient.php b/src/Bridge/AwsBedrock/Language/ModelClient.php index 62b85e71..ea7cfdf9 100644 --- a/src/Bridge/AwsBedrock/Language/ModelClient.php +++ b/src/Bridge/AwsBedrock/Language/ModelClient.php @@ -140,23 +140,27 @@ function (Content $inputContent) { } $inferenceConfig = array_reduce( - array_filter( - $options, - fn ($value, $optionKey) => in_array($optionKey, ['max_tokens', 'stop_sequences', 'temperature', 'top_p']), - ARRAY_FILTER_USE_BOTH + array_keys( + $filteredAttributes = array_filter( + $options, + fn ($value, $optionKey) => in_array($optionKey, ['max_tokens', 'stop_sequences', 'temperature', 'top_p']), + ARRAY_FILTER_USE_BOTH + ) ), - function (array $inferenceConfigAcc, string $optionValue, string $optionKey) use (&$options) { + function (array $inferenceConfigAcc, string $optionKey) use (&$options, &$filteredAttributes) { unset($options[$optionKey]); - $optionKey = implode( - '', - array_map( - fn ($part, $partIndex) => 0 === $partIndex ? mb_lcfirst($part) : mb_ucfirst($part), - explode('_', $optionKey) + $newKey = mb_lcfirst( + implode( + '', + array_map( + fn ($part) => mb_ucfirst($part), + explode('_', $optionKey) + ) ) ); - $inferenceConfigAcc[$optionKey] = $optionValue; + $inferenceConfigAcc[$newKey] = $filteredAttributes[$optionKey]; return $inferenceConfigAcc; }, @@ -164,23 +168,27 @@ function (array $inferenceConfigAcc, string $optionValue, string $optionKey) use ); $additionalModelRequestFields = array_reduce( - array_filter( - $options, - fn ($value, $optionKey) => in_array($optionKey, ['top_k']), - ARRAY_FILTER_USE_BOTH + array_keys( + $filteredAttributes = array_filter( + $options, + fn ($value, $optionKey) => in_array($optionKey, ['top_k']), + ARRAY_FILTER_USE_BOTH + ) ), - function (array $additionalModelRequestFieldsAcc, string $optionValue, string $optionKey) use (&$options) { + function (array $additionalModelRequestFieldsAcc, string $optionKey) use (&$options, &$filteredAttributes) { unset($options[$optionKey]); - $optionKey = implode( - '', - array_map( - fn ($part, $partIndex) => 0 === $partIndex ? mb_lcfirst($part) : mb_ucfirst($part), - explode('_', $optionKey) + $newKey = mb_lcfirst( + implode( + '', + array_map( + fn ($part) => mb_ucfirst($part), + explode('_', $optionKey) + ) ) ); - $additionalModelRequestFieldsAcc[$optionKey] = $optionValue; + $additionalModelRequestFieldsAcc[$newKey] = $filteredAttributes[$optionKey]; return $additionalModelRequestFieldsAcc; }, @@ -196,7 +204,7 @@ function (array $additionalModelRequestFieldsAcc, string $optionValue, string $o ($options['stream'] ?? false) ? 'converse-stream' : 'converse' ), jsonBody: array_filter( - array_merge($options, [ + array_merge($model->getOptions(), $options, [ 'messages' => $messagesMap, 'system' => $systemMessagesMap, 'toolConfig' => $toolConfig, From df8c7e69616742e748c3a1c51f8a28919ff90279 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Wed, 21 May 2025 01:02:53 +0100 Subject: [PATCH 14/15] chore: options merged --- src/Bridge/AwsBedrock/Language/ModelClient.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Bridge/AwsBedrock/Language/ModelClient.php b/src/Bridge/AwsBedrock/Language/ModelClient.php index ea7cfdf9..dab348b6 100644 --- a/src/Bridge/AwsBedrock/Language/ModelClient.php +++ b/src/Bridge/AwsBedrock/Language/ModelClient.php @@ -43,6 +43,8 @@ public function supports(Model $model, array|string|object $input): bool public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface { + $options = array_merge($model->getOptions(), $options); + if ($input instanceof MessageBag) { $systemMessagesMap = null; @@ -204,7 +206,7 @@ function (array $additionalModelRequestFieldsAcc, string $optionKey) use (&$opti ($options['stream'] ?? false) ? 'converse-stream' : 'converse' ), jsonBody: array_filter( - array_merge($model->getOptions(), $options, [ + array_merge($options, [ 'messages' => $messagesMap, 'system' => $systemMessagesMap, 'toolConfig' => $toolConfig, From 36c646f8a27e9afce21cb21d63727a61f5a73ff3 Mon Sep 17 00:00:00 2001 From: Vin Souza Date: Wed, 21 May 2025 10:35:29 +0100 Subject: [PATCH 15/15] style: php-cs-fixer --- src/Bridge/AwsBedrock/AmazonNova.php | 2 +- src/Bridge/AwsBedrock/Embeddings.php | 2 +- src/Bridge/AwsBedrock/Language/ModelClient.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Bridge/AwsBedrock/AmazonNova.php b/src/Bridge/AwsBedrock/AmazonNova.php index 3544d561..84728ee8 100644 --- a/src/Bridge/AwsBedrock/AmazonNova.php +++ b/src/Bridge/AwsBedrock/AmazonNova.php @@ -19,7 +19,7 @@ public function __construct( private string $name = self::NOVA_LITE_V1, private array $options = [], - private ?string $inferenceProfileRegion = null + private ?string $inferenceProfileRegion = null, ) { } diff --git a/src/Bridge/AwsBedrock/Embeddings.php b/src/Bridge/AwsBedrock/Embeddings.php index 02219b78..19797239 100644 --- a/src/Bridge/AwsBedrock/Embeddings.php +++ b/src/Bridge/AwsBedrock/Embeddings.php @@ -12,7 +12,7 @@ public function __construct( private string $name = self::TITAN_EMBED_TEXT_V2, private array $options = [], - private ?string $inferenceProfileRegion = null + private ?string $inferenceProfileRegion = null, ) { } diff --git a/src/Bridge/AwsBedrock/Language/ModelClient.php b/src/Bridge/AwsBedrock/Language/ModelClient.php index dab348b6..2944059a 100644 --- a/src/Bridge/AwsBedrock/Language/ModelClient.php +++ b/src/Bridge/AwsBedrock/Language/ModelClient.php @@ -106,7 +106,7 @@ function (Content $inputContent) { 'image/jpg', 'image/jpeg' => 'jpeg', 'image/gif' => 'gif', 'image/webp' => 'webp', - default => throw new \Exception('Invalid Image type') + default => throw new \Exception('Invalid Image type'), }, 'source' => [ 'bytes' => $inputContent->asBase64(),