Skip to content

Commit 746a43c

Browse files
committed
refactor: message content file handling
1 parent 54e13cb commit 746a43c

24 files changed

+295
-147
lines changed

composer.json

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
],
1616
"require": {
1717
"php": ">=8.2",
18+
"ext-fileinfo": "*",
1819
"oskarstark/enum-helper": "^1.5",
1920
"phpdocumentor/reflection-docblock": "^5.4",
2021
"phpstan/phpdoc-parser": "^2.1",

examples/audio-transcript-whisper.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory;
44
use PhpLlm\LlmChain\Bridge\OpenAI\Whisper;
5-
use PhpLlm\LlmChain\Bridge\OpenAI\Whisper\File;
5+
use PhpLlm\LlmChain\Model\Message\Content\Audio;
66
use Symfony\Component\Dotenv\Dotenv;
77

88
require_once dirname(__DIR__).'/vendor/autoload.php';
@@ -15,7 +15,7 @@
1515

1616
$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']);
1717
$model = new Whisper();
18-
$file = new File(dirname(__DIR__).'/tests/Fixture/audio.mp3');
18+
$file = Audio::fromFile(dirname(__DIR__).'/tests/Fixture/audio.mp3');
1919

2020
$response = $platform->request($model, $file);
2121

examples/image-describer-binary-gemini.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
Message::forSystem('You are an image analyzer bot that helps identify the content of images.'),
2525
Message::ofUser(
2626
'Describe the image as a comedian would do it.',
27-
new Image(dirname(__DIR__).'/tests/Fixture/image.jpg'),
27+
Image::fromFile(dirname(__DIR__).'/tests/Fixture/image.jpg'),
2828
),
2929
);
3030
$response = $chain->call($messages);

examples/image-describer-binary.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
Message::forSystem('You are an image analyzer bot that helps identify the content of images.'),
2525
Message::ofUser(
2626
'Describe the image as a comedian would do it.',
27-
new Image(dirname(__DIR__).'/tests/Fixture/image.jpg'),
27+
Image::fromFile(dirname(__DIR__).'/tests/Fixture/image.jpg'),
2828
),
2929
);
3030
$response = $chain->call($messages);

examples/image-describer-url.php

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use PhpLlm\LlmChain\Bridge\OpenAI\GPT;
44
use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory;
55
use PhpLlm\LlmChain\Chain;
6-
use PhpLlm\LlmChain\Model\Message\Content\Image;
6+
use PhpLlm\LlmChain\Model\Message\Content\ImageUrl;
77
use PhpLlm\LlmChain\Model\Message\Message;
88
use PhpLlm\LlmChain\Model\Message\MessageBag;
99
use Symfony\Component\Dotenv\Dotenv;
@@ -24,7 +24,7 @@
2424
Message::forSystem('You are an image analyzer bot that helps identify the content of images.'),
2525
Message::ofUser(
2626
'Describe the image as a comedian would do it.',
27-
new Image('https://upload.wikimedia.org/wikipedia/commons/thumb/3/31/Webysther_20160423_-_Elephpant.svg/350px-Webysther_20160423_-_Elephpant.svg.png'),
27+
new ImageUrl('https://upload.wikimedia.org/wikipedia/commons/thumb/3/31/Webysther_20160423_-_Elephpant.svg/350px-Webysther_20160423_-_Elephpant.svg.png'),
2828
),
2929
);
3030
$response = $chain->call($messages);

src/Bridge/Azure/OpenAI/WhisperModelClient.php

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
namespace PhpLlm\LlmChain\Bridge\Azure\OpenAI;
66

77
use PhpLlm\LlmChain\Bridge\OpenAI\Whisper;
8-
use PhpLlm\LlmChain\Bridge\OpenAI\Whisper\File;
8+
use PhpLlm\LlmChain\Model\Message\Content\Audio;
99
use PhpLlm\LlmChain\Model\Model;
1010
use PhpLlm\LlmChain\Platform\ModelClient;
1111
use Symfony\Component\HttpClient\EventSourceHttpClient;
@@ -34,12 +34,12 @@ public function __construct(
3434

3535
public function supports(Model $model, object|array|string $input): bool
3636
{
37-
return $model instanceof Whisper && $input instanceof File;
37+
return $model instanceof Whisper && $input instanceof Audio;
3838
}
3939

4040
public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface
4141
{
42-
assert($input instanceof File);
42+
assert($input instanceof Audio);
4343

4444
$url = sprintf('https://%s/openai/deployments/%s/audio/translations', $this->baseUrl, $this->deployment);
4545

@@ -51,7 +51,7 @@ public function request(Model $model, object|array|string $input, array $options
5151
'query' => ['api-version' => $this->apiVersion],
5252
'body' => array_merge($options, $model->getOptions(), [
5353
'model' => $model->getName(),
54-
'file' => fopen($input->path, 'r'),
54+
'file' => $input->asResource(),
5555
]),
5656
]);
5757
}

src/Bridge/Google/GooglePromptConverter.php

+2-4
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212
use PhpLlm\LlmChain\Model\Message\Role;
1313
use PhpLlm\LlmChain\Model\Message\UserMessage;
1414

15-
use function Symfony\Component\String\u;
16-
1715
final class GooglePromptConverter
1816
{
1917
/**
@@ -63,8 +61,8 @@ private function convertMessage(MessageInterface $message): array
6361
}
6462
if ($content instanceof Image) {
6563
$parts[] = ['inline_data' => [
66-
'mime_type' => u($content->url)->after('data:')->before(';')->toString(),
67-
'data' => u($content->url)->after('base64,')->toString(),
64+
'mime_type' => $content->getFormat(),
65+
'data' => $content->asBase64(),
6866
]];
6967
}
7068
}

src/Bridge/Meta/LlamaPromptConverter.php

+3-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
use PhpLlm\LlmChain\Exception\RuntimeException;
88
use PhpLlm\LlmChain\Model\Message\AssistantMessage;
9-
use PhpLlm\LlmChain\Model\Message\Content\Image;
9+
use PhpLlm\LlmChain\Model\Message\Content\ImageUrl;
1010
use PhpLlm\LlmChain\Model\Message\Content\Text;
1111
use PhpLlm\LlmChain\Model\Message\MessageBagInterface;
1212
use PhpLlm\LlmChain\Model\Message\SystemMessage;
@@ -60,7 +60,7 @@ public function convertMessage(UserMessage|SystemMessage|AssistantMessage $messa
6060
$contentParts[] = $value->text;
6161
}
6262

63-
if ($value instanceof Image) {
63+
if ($value instanceof ImageUrl) {
6464
$contentParts[] = $value->url;
6565
}
6666
}
@@ -70,7 +70,7 @@ public function convertMessage(UserMessage|SystemMessage|AssistantMessage $messa
7070
$contentParts[] = $value->text;
7171
}
7272

73-
if ($value instanceof Image) {
73+
if ($value instanceof ImageUrl) {
7474
$contentParts[] = $value->url;
7575
}
7676
} else {

src/Bridge/OpenAI/Whisper/File.php

-18
This file was deleted.

src/Bridge/OpenAI/Whisper/ModelClient.php

+4-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace PhpLlm\LlmChain\Bridge\OpenAI\Whisper;
66

77
use PhpLlm\LlmChain\Bridge\OpenAI\Whisper;
8+
use PhpLlm\LlmChain\Model\Message\Content\Audio;
89
use PhpLlm\LlmChain\Model\Model;
910
use PhpLlm\LlmChain\Platform\ModelClient as BaseModelClient;
1011
use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -23,19 +24,19 @@ public function __construct(
2324

2425
public function supports(Model $model, object|array|string $input): bool
2526
{
26-
return $model instanceof Whisper && $input instanceof File;
27+
return $model instanceof Whisper && $input instanceof Audio;
2728
}
2829

2930
public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface
3031
{
31-
assert($input instanceof File);
32+
assert($input instanceof Audio);
3233

3334
return $this->httpClient->request('POST', 'https://api.openai.com/v1/audio/transcriptions', [
3435
'auth_bearer' => $this->apiKey,
3536
'headers' => ['Content-Type' => 'multipart/form-data'],
3637
'body' => array_merge($options, $model->getOptions(), [
3738
'model' => $model->getName(),
38-
'file' => fopen($input->path, 'r'),
39+
'file' => $input->asResource(),
3940
]),
4041
]);
4142
}

src/Bridge/OpenAI/Whisper/ResponseConverter.php

+3-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace PhpLlm\LlmChain\Bridge\OpenAI\Whisper;
66

77
use PhpLlm\LlmChain\Bridge\OpenAI\Whisper;
8+
use PhpLlm\LlmChain\Model\Message\Content\Audio;
89
use PhpLlm\LlmChain\Model\Model;
910
use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse;
1011
use PhpLlm\LlmChain\Model\Response\TextResponse;
@@ -15,12 +16,12 @@ final class ResponseConverter implements BaseResponseConverter
1516
{
1617
public function supports(Model $model, object|array|string $input): bool
1718
{
18-
return $model instanceof Whisper && $input instanceof File;
19+
return $model instanceof Whisper && $input instanceof Audio;
1920
}
2021

2122
public function convert(HttpResponse $response, array $options = []): LlmResponse
2223
{
23-
$data = $response->toArray();
24+
$data = $response->toArray(false);
2425

2526
return new TextResponse($data['text']);
2627
}

src/Model/Message/Content/Audio.php

+7-37
Original file line numberDiff line numberDiff line change
@@ -4,42 +4,8 @@
44

55
namespace PhpLlm\LlmChain\Model\Message\Content;
66

7-
use PhpLlm\LlmChain\Exception\InvalidArgumentException;
8-
9-
use function Symfony\Component\String\u;
10-
11-
final readonly class Audio implements Content
7+
final readonly class Audio extends File implements Content
128
{
13-
public function __construct(
14-
public string $data,
15-
public string $format,
16-
) {
17-
}
18-
19-
public static function fromDataUrl(string $dataUrl): self
20-
{
21-
if (!str_starts_with($dataUrl, 'data:audio/')) {
22-
throw new InvalidArgumentException('Invalid audio data URL format.');
23-
}
24-
25-
return new self(
26-
u($dataUrl)->after('base64,')->toString(),
27-
u($dataUrl)->after('data:audio/')->before(';base64,')->toString(),
28-
);
29-
}
30-
31-
public static function fromFile(string $filePath): self
32-
{
33-
if (!is_readable($filePath) || false === $audioData = file_get_contents($filePath)) {
34-
throw new InvalidArgumentException(sprintf('The file "%s" does not exist or is not readable.', $filePath));
35-
}
36-
37-
return new self(
38-
base64_encode($audioData),
39-
pathinfo($filePath, PATHINFO_EXTENSION)
40-
);
41-
}
42-
439
/**
4410
* @return array{type: 'input_audio', input_audio: array{data: string, format: string}}
4511
*/
@@ -48,8 +14,12 @@ public function jsonSerialize(): array
4814
return [
4915
'type' => 'input_audio',
5016
'input_audio' => [
51-
'data' => $this->data,
52-
'format' => $this->format,
17+
'data' => $this->asBase64(),
18+
'format' => match ($this->getFormat()) {
19+
'audio/mpeg' => 'mp3',
20+
'audio/wav' => 'wav',
21+
default => $this->getFormat(),
22+
},
5323
],
5424
];
5525
}

src/Model/Message/Content/File.php

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Model\Message\Content;
6+
7+
use PhpLlm\LlmChain\Exception\InvalidArgumentException;
8+
9+
use function Symfony\Component\String\u;
10+
11+
readonly class File
12+
{
13+
final public function __construct(
14+
private string|\Closure $data,
15+
private string $format,
16+
private ?string $path = null,
17+
) {
18+
}
19+
20+
public static function fromDataUrl(string $dataUrl): static
21+
{
22+
if (!str_starts_with($dataUrl, 'data:')) {
23+
throw new InvalidArgumentException('Invalid audio data URL format.');
24+
}
25+
26+
return new static(
27+
base64_decode(u($dataUrl)->after('base64,')->toString()),
28+
u($dataUrl)->after('data:')->before(';base64,')->toString(),
29+
);
30+
}
31+
32+
public static function fromFile(string $path): static
33+
{
34+
if (!is_readable($path)) {
35+
throw new \InvalidArgumentException(sprintf('The file "%s" does not exist or is not readable.', $path));
36+
}
37+
38+
return new static(
39+
fn () => file_get_contents($path),
40+
mime_content_type($path),
41+
$path,
42+
);
43+
}
44+
45+
public function getFormat(): string
46+
{
47+
return $this->format;
48+
}
49+
50+
public function asBinary(): string
51+
{
52+
return $this->data instanceof \Closure ? ($this->data)() : $this->data;
53+
}
54+
55+
public function asBase64(): string
56+
{
57+
return base64_encode($this->asBinary());
58+
}
59+
60+
public function asDataUrl(): string
61+
{
62+
return sprintf('data:%s;base64,%s', $this->format, $this->asBase64());
63+
}
64+
65+
/**
66+
* @return resource|false
67+
*/
68+
public function asResource()
69+
{
70+
if (null === $this->path) {
71+
throw new \RuntimeException('You can only get a resource after creating fromFile.');
72+
}
73+
74+
return fopen($this->path, 'r');
75+
}
76+
}

src/Model/Message/Content/Image.php

+5-30
Original file line numberDiff line numberDiff line change
@@ -4,41 +4,16 @@
44

55
namespace PhpLlm\LlmChain\Model\Message\Content;
66

7-
use PhpLlm\LlmChain\Exception\InvalidArgumentException;
8-
9-
final readonly class Image implements Content
7+
final readonly class Image extends File implements Content
108
{
11-
public string $url;
12-
13-
/**
14-
* @param string $url An URL like "http://localhost:3000/my-image.png", a data url like "data:image/png;base64,iVBOR[...]"
15-
* or a file path like "/path/to/my-image.png".
16-
*/
17-
public function __construct(string $url)
18-
{
19-
if (!str_starts_with($url, 'http') && !str_starts_with($url, 'data:')) {
20-
$url = $this->fromFile($url);
21-
}
22-
23-
$this->url = $url;
24-
}
25-
269
/**
2710
* @return array{type: 'image_url', image_url: array{url: string}}
2811
*/
2912
public function jsonSerialize(): array
3013
{
31-
return ['type' => 'image_url', 'image_url' => ['url' => $this->url]];
32-
}
33-
34-
private function fromFile(string $filePath): string
35-
{
36-
if (!is_readable($filePath) || false === $data = file_get_contents($filePath)) {
37-
throw new InvalidArgumentException(sprintf('The file "%s" does not exist or is not readable.', $filePath));
38-
}
39-
40-
$type = pathinfo($filePath, PATHINFO_EXTENSION);
41-
42-
return sprintf('data:image/%s;base64,%s', $type, base64_encode($data));
14+
return [
15+
'type' => 'image_url',
16+
'image_url' => ['url' => $this->asDataUrl()],
17+
];
4318
}
4419
}

0 commit comments

Comments
 (0)