Skip to content

Commit 0409956

Browse files
authored
Merge branch 'php-llm:main' into dall-e-image-generation
2 parents 1998614 + 04edf87 commit 0409956

32 files changed

+576
-248
lines changed

README.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,24 @@ echo $response->getContent(); // "I'm fine, thank you. How can I help you today?
9797

9898
The `MessageInterface` and `Content` interface help to customize this process if needed, e.g. additional state handling.
9999

100+
#### Options
101+
102+
The second parameter of the `call` method is an array of options, which can be used to configure the behavior of the
103+
chain, like `stream`, `output_structure`, or `response_format`. This behavior is a combination of features provided by
104+
the underlying model and platform, or additional features provided by processors registered to the chain.
105+
106+
Options design for additional features provided by LLM Chain can be found in this documentation. For model and platform
107+
specific options, please refer to the respective documentation.
108+
109+
```php
110+
// Chain and MessageBag instantiation
111+
112+
$response = $chain->call($messages, [
113+
'temperature' => 0.5, // example option controlling the randomness of the response, e.g. GPT and Claude
114+
'n' => 3, // example option controlling the number of responses generated, e.g. GPT
115+
]);
116+
```
117+
100118
#### Code Examples
101119

102120
1. **Anthropic's Claude**: [chat-claude-anthropic.php](examples/chat-claude-anthropic.php)
@@ -208,11 +226,27 @@ See attribute class [ToolParameter](src/Chain/ToolBox/Attribute/ToolParameter.ph
208226
> [!NOTE]
209227
> Please be aware, that this is only converted in a JSON Schema for the LLM to respect, but not validated by LLM Chain.
210228
229+
#### Tool Result Interception
230+
231+
To react to the result of a tool, you can implement an EventListener or EventSubscriber, that listens to the
232+
`ToolCallsExecuted` event. This event is dispatched after the `ToolBox` executed all current tool calls and enables
233+
you to skip the next LLM call by setting a response yourself:
234+
235+
```php
236+
$eventDispatcher->addListener(ToolCallsExecuted::class, function (ToolCallsExecuted $event): void {
237+
foreach ($event->toolCallResults as $toolCallResult) {
238+
if (str_starts_with($toolCallResult->toolCall->name, 'weather_')) {
239+
$event->response = new StructuredResponse($toolCallResult->result);
240+
}
241+
}
242+
});
243+
```
244+
211245
#### Code Examples (with built-in tools)
212246

213247
1. **Clock Tool**: [toolbox-clock.php](examples/toolbox-clock.php)
214248
1. **SerpAPI Tool**: [toolbox-serpapi.php](examples/toolbox-serpapi.php)
215-
1. **Weather Tool**: [toolbox-weather.php](examples/toolbox-weather.php)
249+
1. **Weather Tool with Event Listener**: [toolbox-weather-event.php](examples/toolbox-weather-event.php)
216250
1. **Wikipedia Tool**: [toolbox-wikipedia.php](examples/toolbox-wikipedia.php)
217251
1. **YouTube Transcriber Tool**: [toolbox-youtube.php](examples/toolbox-youtube.php) (with streaming)
218252

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"symfony/css-selector": "^6.4 || ^7.1",
3838
"symfony/dom-crawler": "^6.4 || ^7.1",
3939
"symfony/dotenv": "^6.4 || ^7.1",
40+
"symfony/event-dispatcher": "^6.4 || ^7.1",
4041
"symfony/finder": "^6.4 || ^7.1",
4142
"symfony/process": "^6.4 || ^7.1",
4243
"symfony/var-dumper": "^6.4 || ^7.1"

examples/toolbox-weather.php renamed to examples/chat-system-prompt.php

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,10 @@
33
use PhpLlm\LlmChain\Bridge\OpenAI\GPT;
44
use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory;
55
use PhpLlm\LlmChain\Chain;
6-
use PhpLlm\LlmChain\Chain\ToolBox\ChainProcessor;
7-
use PhpLlm\LlmChain\Chain\ToolBox\Tool\OpenMeteo;
8-
use PhpLlm\LlmChain\Chain\ToolBox\ToolAnalyzer;
9-
use PhpLlm\LlmChain\Chain\ToolBox\ToolBox;
6+
use PhpLlm\LlmChain\Chain\InputProcessor\SystemPromptInputProcessor;
107
use PhpLlm\LlmChain\Model\Message\Message;
118
use PhpLlm\LlmChain\Model\Message\MessageBag;
129
use Symfony\Component\Dotenv\Dotenv;
13-
use Symfony\Component\HttpClient\HttpClient;
1410

1511
require_once dirname(__DIR__).'/vendor/autoload.php';
1612
(new Dotenv())->loadEnv(dirname(__DIR__).'/.env');
@@ -23,12 +19,10 @@
2319
$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']);
2420
$llm = new GPT(GPT::GPT_4O_MINI);
2521

26-
$wikipedia = new OpenMeteo(HttpClient::create());
27-
$toolBox = new ToolBox(new ToolAnalyzer(), [$wikipedia]);
28-
$processor = new ChainProcessor($toolBox);
29-
$chain = new Chain($platform, $llm, [$processor], [$processor]);
22+
$processor = new SystemPromptInputProcessor('You are Yoda and write like he speaks. But short.');
3023

31-
$messages = new MessageBag(Message::ofUser('How is the weather currently in Berlin? And how about tomorrow?'));
24+
$chain = new Chain($platform, $llm, [$processor]);
25+
$messages = new MessageBag(Message::ofUser('What is the meaning of life?'));
3226
$response = $chain->call($messages);
3327

3428
echo $response->getContent().PHP_EOL;

examples/toolbox-weather-event.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
use PhpLlm\LlmChain\Bridge\OpenAI\GPT;
4+
use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory;
5+
use PhpLlm\LlmChain\Chain;
6+
use PhpLlm\LlmChain\Chain\ToolBox\ChainProcessor;
7+
use PhpLlm\LlmChain\Chain\ToolBox\Event\ToolCallsExecuted;
8+
use PhpLlm\LlmChain\Chain\ToolBox\Tool\OpenMeteo;
9+
use PhpLlm\LlmChain\Chain\ToolBox\ToolAnalyzer;
10+
use PhpLlm\LlmChain\Chain\ToolBox\ToolBox;
11+
use PhpLlm\LlmChain\Model\Message\Message;
12+
use PhpLlm\LlmChain\Model\Message\MessageBag;
13+
use PhpLlm\LlmChain\Model\Response\StructuredResponse;
14+
use Symfony\Component\Dotenv\Dotenv;
15+
use Symfony\Component\EventDispatcher\EventDispatcher;
16+
use Symfony\Component\HttpClient\HttpClient;
17+
18+
require_once dirname(__DIR__).'/vendor/autoload.php';
19+
(new Dotenv())->loadEnv(dirname(__DIR__).'/.env');
20+
21+
if (empty($_ENV['OPENAI_API_KEY'])) {
22+
echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL;
23+
exit(1);
24+
}
25+
26+
$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']);
27+
$llm = new GPT(GPT::GPT_4O_MINI);
28+
29+
$openMeteo = new OpenMeteo(HttpClient::create());
30+
$toolBox = new ToolBox(new ToolAnalyzer(), [$openMeteo]);
31+
$eventDispatcher = new EventDispatcher();
32+
$processor = new ChainProcessor($toolBox, eventDispatcher: $eventDispatcher);
33+
$chain = new Chain($platform, $llm, [$processor], [$processor]);
34+
35+
// Add tool call result listener to enforce chain exits direct with structured response for weather tools
36+
$eventDispatcher->addListener(ToolCallsExecuted::class, function (ToolCallsExecuted $event): void {
37+
foreach ($event->toolCallResults as $toolCallResult) {
38+
if (str_starts_with($toolCallResult->toolCall->name, 'weather_')) {
39+
$event->response = new StructuredResponse($toolCallResult->result);
40+
}
41+
}
42+
});
43+
44+
$messages = new MessageBag(Message::ofUser('How is the weather currently in Berlin?'));
45+
$response = $chain->call($messages);
46+
47+
dump($response->getContent());

src/Bridge/OpenAI/GPT/ResponseConverter.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ private function convertStream(HttpResponse $response): ToolCallResponse|StreamR
5959

6060
if ($this->streamIsToolCall($stream)) {
6161
return new ToolCallResponse(...$this->convertStreamToToolCalls($stream));
62-
} else {
63-
return new StreamResponse($this->convertStreamContent($stream));
6462
}
63+
64+
return new StreamResponse($this->convertStreamContent($stream));
6565
}
6666

6767
private function streamResponse(HttpResponse $response): \Generator

src/Chain.php

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,74 +11,85 @@
1111
use PhpLlm\LlmChain\Chain\OutputProcessor;
1212
use PhpLlm\LlmChain\Exception\InvalidArgumentException;
1313
use PhpLlm\LlmChain\Exception\MissingModelSupport;
14+
use PhpLlm\LlmChain\Exception\RuntimeException;
1415
use PhpLlm\LlmChain\Model\LanguageModel;
1516
use PhpLlm\LlmChain\Model\Message\MessageBagInterface;
1617
use PhpLlm\LlmChain\Model\Response\AsyncResponse;
1718
use PhpLlm\LlmChain\Model\Response\ResponseInterface;
19+
use Psr\Log\LoggerInterface;
20+
use Psr\Log\NullLogger;
21+
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
22+
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
1823

1924
final readonly class Chain implements ChainInterface
2025
{
2126
/**
2227
* @var InputProcessor[]
2328
*/
24-
private array $inputProcessor;
29+
private array $inputProcessors;
2530

2631
/**
2732
* @var OutputProcessor[]
2833
*/
29-
private array $outputProcessor;
34+
private array $outputProcessors;
3035

3136
/**
32-
* @param InputProcessor[] $inputProcessor
33-
* @param OutputProcessor[] $outputProcessor
37+
* @param InputProcessor[] $inputProcessors
38+
* @param OutputProcessor[] $outputProcessors
3439
*/
3540
public function __construct(
3641
private PlatformInterface $platform,
3742
private LanguageModel $llm,
38-
iterable $inputProcessor = [],
39-
iterable $outputProcessor = [],
43+
iterable $inputProcessors = [],
44+
iterable $outputProcessors = [],
45+
private LoggerInterface $logger = new NullLogger(),
4046
) {
41-
$this->inputProcessor = $this->initializeProcessors($inputProcessor, InputProcessor::class);
42-
$this->outputProcessor = $this->initializeProcessors($outputProcessor, OutputProcessor::class);
47+
$this->inputProcessors = $this->initializeProcessors($inputProcessors, InputProcessor::class);
48+
$this->outputProcessors = $this->initializeProcessors($outputProcessors, OutputProcessor::class);
4349
}
4450

4551
/**
4652
* @param array<string, mixed> $options
4753
*/
4854
public function call(MessageBagInterface $messages, array $options = []): ResponseInterface
4955
{
50-
$llm = $this->llm;
56+
$input = new Input($this->llm, $messages, $options);
57+
array_map(fn (InputProcessor $processor) => $processor->processInput($input), $this->inputProcessors);
5158

52-
if (array_key_exists('llm', $options)) {
53-
if (!$options['llm'] instanceof LanguageModel) {
54-
throw new InvalidArgumentException(sprintf('Option "llm" must be an instance of %s.', LanguageModel::class));
55-
}
56-
57-
$llm = $options['llm'];
58-
unset($options['llm']);
59-
}
60-
61-
$input = new Input($llm, $messages, $options);
62-
array_map(fn (InputProcessor $processor) => $processor->processInput($input), $this->inputProcessor);
59+
$llm = $input->llm;
60+
$messages = $input->messages;
61+
$options = $input->getOptions();
6362

6463
if ($messages->containsImage() && !$llm->supportsImageInput()) {
6564
throw MissingModelSupport::forImageInput($llm::class);
6665
}
6766

68-
$response = $this->platform->request($llm, $messages, $options = $input->getOptions());
67+
try {
68+
$response = $this->platform->request($llm, $messages, $options);
69+
70+
if ($response instanceof AsyncResponse) {
71+
$response = $response->unwrap();
72+
}
73+
} catch (ClientExceptionInterface $e) {
74+
$message = $e->getMessage();
75+
$content = $e->getResponse()->toArray(false);
76+
77+
$this->logger->debug($message, $content);
6978

70-
if ($response instanceof AsyncResponse) {
71-
$response = $response->unwrap();
79+
throw new InvalidArgumentException('' === $message ? 'Invalid request to model or platform' : $message, 0, $e);
80+
} catch (HttpExceptionInterface $e) {
81+
throw new RuntimeException('Failed to request model', 0, $e);
7282
}
7383

7484
$output = new Output($llm, $response, $messages, $options);
75-
array_map(fn (OutputProcessor $processor) => $processor->processOutput($output), $this->outputProcessor);
85+
array_map(fn (OutputProcessor $processor) => $processor->processOutput($output), $this->outputProcessors);
7686

7787
return $output->response;
7888
}
7989

8090
/**
8191
* @param InputProcessor[]|OutputProcessor[] $processors
92+
* @param class-string $interface
8293
*
8394
* @return InputProcessor[]|OutputProcessor[]
8495
*/

src/Chain/Input.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ final class Input
1313
* @param array<string, mixed> $options
1414
*/
1515
public function __construct(
16-
public readonly LanguageModel $llm,
17-
public readonly MessageBagInterface $messages,
16+
public LanguageModel $llm,
17+
public MessageBagInterface $messages,
1818
private array $options,
1919
) {
2020
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Chain\InputProcessor;
6+
7+
use PhpLlm\LlmChain\Chain\Input;
8+
use PhpLlm\LlmChain\Chain\InputProcessor;
9+
use PhpLlm\LlmChain\Exception\InvalidArgumentException;
10+
use PhpLlm\LlmChain\Model\LanguageModel;
11+
12+
final class LlmOverrideInputProcessor implements InputProcessor
13+
{
14+
public function processInput(Input $input): void
15+
{
16+
$options = $input->getOptions();
17+
18+
if (!array_key_exists('llm', $options)) {
19+
return;
20+
}
21+
22+
if (!$options['llm'] instanceof LanguageModel) {
23+
throw new InvalidArgumentException(sprintf('Option "llm" must be an instance of %s.', LanguageModel::class));
24+
}
25+
26+
$input->llm = $options['llm'];
27+
}
28+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Chain\InputProcessor;
6+
7+
use PhpLlm\LlmChain\Chain\Input;
8+
use PhpLlm\LlmChain\Chain\InputProcessor;
9+
use PhpLlm\LlmChain\Model\Message\Message;
10+
use Psr\Log\LoggerInterface;
11+
use Psr\Log\NullLogger;
12+
13+
final readonly class SystemPromptInputProcessor implements InputProcessor
14+
{
15+
public function __construct(
16+
private string $systemPrompt,
17+
private LoggerInterface $logger = new NullLogger(),
18+
) {
19+
}
20+
21+
public function processInput(Input $input): void
22+
{
23+
$messages = $input->messages;
24+
25+
if (null !== $messages->getSystemMessage()) {
26+
$this->logger->debug('Skipping system prompt injection since MessageBag already contains a system message.');
27+
28+
return;
29+
}
30+
31+
$input->messages = $messages->prepend(Message::forSystem($this->systemPrompt));
32+
}
33+
}

src/Chain/ToolBox/ChainProcessor.php

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,20 @@
1010
use PhpLlm\LlmChain\Chain\InputProcessor;
1111
use PhpLlm\LlmChain\Chain\Output;
1212
use PhpLlm\LlmChain\Chain\OutputProcessor;
13+
use PhpLlm\LlmChain\Chain\ToolBox\Event\ToolCallsExecuted;
1314
use PhpLlm\LlmChain\Exception\MissingModelSupport;
1415
use PhpLlm\LlmChain\Model\Message\Message;
1516
use PhpLlm\LlmChain\Model\Response\ToolCallResponse;
17+
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
1618

1719
final class ChainProcessor implements InputProcessor, OutputProcessor, ChainAwareProcessor
1820
{
1921
use ChainAwareTrait;
2022

2123
public function __construct(
22-
private ToolBoxInterface $toolBox,
24+
private readonly ToolBoxInterface $toolBox,
25+
private ToolResultConverter $resultConverter = new ToolResultConverter(),
26+
private readonly ?EventDispatcherInterface $eventDispatcher = null,
2327
) {
2428
}
2529

@@ -47,12 +51,17 @@ public function processOutput(Output $output): void
4751
$toolCalls = $output->response->getContent();
4852
$messages->add(Message::ofAssistant(toolCalls: $toolCalls));
4953

54+
$results = [];
5055
foreach ($toolCalls as $toolCall) {
5156
$result = $this->toolBox->execute($toolCall);
52-
$messages->add(Message::ofToolCall($toolCall, $result));
57+
$results[] = new ToolCallResult($toolCall, $result);
58+
$messages->add(Message::ofToolCall($toolCall, $this->resultConverter->convert($result)));
5359
}
5460

55-
$output->response = $this->chain->call($messages, $output->options);
61+
$event = new ToolCallsExecuted(...$results);
62+
$this->eventDispatcher?->dispatch($event);
63+
64+
$output->response = $event->hasResponse() ? $event->response : $this->chain->call($messages, $output->options);
5665
}
5766
}
5867
}

0 commit comments

Comments
 (0)