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/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/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/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/AwsBedrock/AmazonNova.php b/src/Bridge/AwsBedrock/AmazonNova.php new file mode 100644 index 00000000..84728ee8 --- /dev/null +++ b/src/Bridge/AwsBedrock/AmazonNova.php @@ -0,0 +1,64 @@ + $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; // Only videos (!?) + } + + public function supportsImageInput(): bool + { + return true; + } + + public function supportsStreaming(): bool + { + return false; // It does, but it's not implemented yet. + } + + public function supportsToolCalling(): bool + { + return true; + } + + public function supportsStructuredOutput(): bool + { + return false; + } +} 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/Embeddings.php b/src/Bridge/AwsBedrock/Embeddings.php new file mode 100644 index 00000000..19797239 --- /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..a4d1ef85 --- /dev/null +++ b/src/Bridge/AwsBedrock/Embeddings/ModelClient.php @@ -0,0 +1,51 @@ +requestSigner->signRequest( + method: 'POST', + endpoint: $bedrockEndpoint = sprintf( + 'https://bedrock-runtime.%s.amazonaws.com/model/%s/invoke', + $this->region, + $model->getName(), + ), + 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/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 new file mode 100644 index 00000000..2944059a --- /dev/null +++ b/src/Bridge/AwsBedrock/Language/ModelClient.php @@ -0,0 +1,226 @@ +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 + { + return $input instanceof MessageBag && $model instanceof BedrockLanguageModel; + } + + public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface + { + $options = array_merge($model->getOptions(), $options); + + 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) + ), + ], + ]; + } 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 + ), + ]; + } + }, + $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_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 $optionKey) use (&$options, &$filteredAttributes) { + unset($options[$optionKey]); + + $newKey = mb_lcfirst( + implode( + '', + array_map( + fn ($part) => mb_ucfirst($part), + explode('_', $optionKey) + ) + ) + ); + + $inferenceConfigAcc[$newKey] = $filteredAttributes[$optionKey]; + + return $inferenceConfigAcc; + }, + [] + ); + + $additionalModelRequestFields = array_reduce( + array_keys( + $filteredAttributes = array_filter( + $options, + fn ($value, $optionKey) => in_array($optionKey, ['top_k']), + ARRAY_FILTER_USE_BOTH + ) + ), + function (array $additionalModelRequestFieldsAcc, string $optionKey) use (&$options, &$filteredAttributes) { + unset($options[$optionKey]); + + $newKey = mb_lcfirst( + implode( + '', + array_map( + fn ($part) => mb_ucfirst($part), + explode('_', $optionKey) + ) + ) + ); + + $additionalModelRequestFieldsAcc[$newKey] = $filteredAttributes[$optionKey]; + + 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..f4019bc3 --- /dev/null +++ b/src/Bridge/AwsBedrock/Language/ResponseConverter.php @@ -0,0 +1,99 @@ +toArray(); + } catch (ClientExceptionInterface $e) { + if (400 === $response->getStatusCode()) { + throw new ContentFilterException(message: 'Validation error', previous: $e); + } + + throw $e; + } + + if (!isset($data['output']['message']['content'])) { + throw new RuntimeException('Response does not contain choices'); + } + + $stopReason = $data['stopReason']; + + /** @var Choice[] $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); + } + + if ($choices[0]->hasToolCall()) { + return new ToolCallResponse(...$choices[0]->getToolCalls()); + } + + return new TextResponse($choices[0]->getContent()); + } + + private function convertChoice(array $choice, string $stopReason): Choice + { + if (isset($choice['toolUse'])) { + return new Choice( + toolCalls: [ + $this->convertToolCall($choice['toolUse']), + ] + ); + } + + if (isset($choice['text'])) { + return new Choice( + $choice['text'] + ); + } + + throw new RuntimeException(sprintf('Unsupported finish reason "%s".', $stopReason)); + } + + private function convertToolCall(array $toolCall): ToolCall + { + return new ToolCall($toolCall['toolUseId'], $toolCall['name'], $toolCall['input']); + } +} diff --git a/src/Bridge/AwsBedrock/PlatformFactory.php b/src/Bridge/AwsBedrock/PlatformFactory.php new file mode 100644 index 00000000..975f492e --- /dev/null +++ b/src/Bridge/AwsBedrock/PlatformFactory.php @@ -0,0 +1,43 @@ +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(); + + $content = $rawResponse->toArray(false); + + if (!\array_key_exists('usage', $content)) { + return; + } + + $metadata->add('prompt_tokens', $content['usage']['inputTokens'] ?? null); + $metadata->add('completion_tokens', $content['usage']['outputTokens'] ?? null); + $metadata->add('total_tokens', $content['usage']['totalTokens'] ?? null); + } +} 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 @@ + $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']); + } } diff --git a/src/Bridge/OpenAI/GPT.php b/src/Bridge/OpenAI/GPT.php index e2ed6316..321aa03e 100644 --- a/src/Bridge/OpenAI/GPT.php +++ b/src/Bridge/OpenAI/GPT.php @@ -4,9 +4,9 @@ namespace PhpLlm\LlmChain\Bridge\OpenAI; -use PhpLlm\LlmChain\Model\LanguageModel; +use PhpLlm\LlmChain\Model\OpenAiCompatibleLanguageModel; -final class GPT implements LanguageModel +final class GPT implements OpenAiCompatibleLanguageModel { public const GPT_35_TURBO = 'gpt-3.5-turbo'; public const GPT_35_TURBO_INSTRUCT = 'gpt-3.5-turbo-instruct'; diff --git a/src/Bridge/OpenAI/GPT/ResponseConverter.php b/src/Bridge/OpenAI/GPT/ResponseConverter.php index 247003e5..3f024d99 100644 --- a/src/Bridge/OpenAI/GPT/ResponseConverter.php +++ b/src/Bridge/OpenAI/GPT/ResponseConverter.php @@ -4,10 +4,10 @@ namespace PhpLlm\LlmChain\Bridge\OpenAI\GPT; -use PhpLlm\LlmChain\Bridge\OpenAI\GPT; use PhpLlm\LlmChain\Exception\ContentFilterException; use PhpLlm\LlmChain\Exception\RuntimeException; use PhpLlm\LlmChain\Model\Model; +use PhpLlm\LlmChain\Model\OpenAiCompatibleLanguageModel; use PhpLlm\LlmChain\Model\Response\Choice; use PhpLlm\LlmChain\Model\Response\ChoiceResponse; use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse; @@ -26,7 +26,7 @@ final class ResponseConverter implements PlatformResponseConverter { public function supports(Model $model, array|string|object $input): bool { - return $model instanceof GPT; + return $model instanceof OpenAiCompatibleLanguageModel; } public function convert(HttpResponse $response, array $options = []): LlmResponse 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 @@ +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 @@ +