Skip to content

Commit 6404b5f

Browse files
nmdimasiBotPeaches
andauthored
feat(LiteLLM): add support for images in chat endpoint (#685)
* Add optional image support to CreateResponseMessage for Nano Banana (litellm + gemini-flash-2.5-image-preview) * Refine type hints for optional attributes in chat response models * fix composer test:lint * Add `CreateResponseChoiceImage` model and integrate image handling in `CreateResponseMessage` * Fix Code Style * chore: use php stan types * test: add tests --------- Co-authored-by: Connor Tumbleson <[email protected]>
1 parent f7d3150 commit 6404b5f

File tree

8 files changed

+147
-11
lines changed

8 files changed

+147
-11
lines changed

src/Resources/Chat.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public function create(array $parameters): CreateResponse
2929

3030
$payload = Payload::create('chat/completions', $parameters);
3131

32-
/** @var Response<array{id: string, object: string, created: int, model: string, system_fingerprint?: string, choices: array<int, array{index: int, message: array{role: string, content: ?string, function_call: ?array{name: string, arguments: string}, tool_calls: ?array<int, array{id: string, type: string, function: array{name: string, arguments: string}}>}, logprobs: ?array{content: ?array<int, array{token: string, logprob: float, bytes: ?array<int, int>}>}, finish_reason: string|null}>, usage: array{prompt_tokens: int, completion_tokens: int|null, total_tokens: int}}> $response */
32+
/** @var Response<array{id: string, object: string, created: int, model: string, system_fingerprint?: string, choices: array<int, array{index: int, message: array{role: string, content: ?string, function_call?: array{name: string, arguments: string}, tool_calls?: array<int, array{id: string, type: string, function: array{name: string, arguments: string}}>}, logprobs: ?array{content: ?array<int, array{token: string, logprob: float, bytes: ?array<int, int>}>}, finish_reason: string|null}>, usage: array{prompt_tokens: int, completion_tokens: int|null, total_tokens: int}}> $response */
3333
$response = $this->transporter->requestObject($payload);
3434

3535
return CreateResponse::from($response->data(), $response->meta());

src/Responses/Chat/CreateResponse.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ private function __construct(
4141
/**
4242
* Acts as static factory, and returns a new Response instance.
4343
*
44-
* @param array{id?: string, object: string, created: int, model: string, system_fingerprint?: string, choices: array<int, array{index: int, message: array{role: string, content: ?string, annotations?: array<int, array{type: string, url_citation: array{start_index: int, end_index: int, title: string, url: string}}>, function_call: ?array{name: string, arguments: string}, tool_calls: ?array<int, array{id: string, type: string, function: array{name: string, arguments: string}}>}, logprobs: ?array{content: ?array<int, array{token: string, logprob: float, bytes: ?array<int, int>}>}, finish_reason: string|null}>, usage?: array{prompt_tokens: int, completion_tokens: int|null, total_tokens: int, prompt_tokens_details?:array{cached_tokens:int}, completion_tokens_details?:array{audio_tokens?:int, reasoning_tokens:int, accepted_prediction_tokens:int, rejected_prediction_tokens:int}}} $attributes
44+
* @param array{id?: string, object: string, created: int, model: string, system_fingerprint?: string, choices: array<int, array{index: int, message: array{role: string, content: ?string, annotations?: array<int, array{type: string, url_citation: array{start_index: int, end_index: int, title: string, url: string}}>, function_call?: array{name: string, arguments: string}, tool_calls?: array<int, array{id: string, type: string, function: array{name: string, arguments: string}}>}, logprobs: ?array{content: ?array<int, array{token: string, logprob: float, bytes: ?array<int, int>}>}, finish_reason: string|null}>, usage?: array{prompt_tokens: int, completion_tokens: int|null, total_tokens: int, prompt_tokens_details?:array{cached_tokens:int}, completion_tokens_details?:array{audio_tokens?:int, reasoning_tokens:int, accepted_prediction_tokens:int, rejected_prediction_tokens:int}}} $attributes
4545
*/
4646
public static function from(array $attributes, MetaInformation $meta): self
4747
{

src/Responses/Chat/CreateResponseChoice.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ private function __construct(
1414
) {}
1515

1616
/**
17-
* @param array{index: int, message: array{role: string, content: ?string, annotations?: array<int, array{type: string, url_citation: array{start_index: int, end_index: int, title: string, url: string}}>, function_call: ?array{name: string, arguments: string}, tool_calls: ?array<int, array{id: string, type: string, function: array{name: string, arguments: string}}>} ,logprobs?: ?array{content: ?array<int, array{token: string, logprob: float, bytes: ?array<int, int>}>}, finish_reason: string|null} $attributes
17+
* @param array{index: int, message: array{role: string, content: ?string, annotations?: array<int, array{type: string, url_citation: array{start_index: int, end_index: int, title: string, url: string}}>, function_call?: array{name: string, arguments: string}, tool_calls?: array<int, array{id: string, type: string, function: array{name: string, arguments: string}}>} ,logprobs?: ?array{content: ?array<int, array{token: string, logprob: float, bytes: ?array<int, int>}>}, finish_reason: string|null} $attributes
1818
*/
1919
public static function from(array $attributes): self
2020
{
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace OpenAI\Responses\Chat;
6+
7+
use OpenAI\Contracts\ResponseContract;
8+
use OpenAI\Responses\Concerns\ArrayAccessible;
9+
use OpenAI\Testing\Responses\Concerns\Fakeable;
10+
11+
/**
12+
* @phpstan-type CreateResponseChoiceImageType array{image_url: array{url: string, detail: string}, index: int, type: string}
13+
*
14+
* @implements ResponseContract<CreateResponseChoiceImageType>
15+
*/
16+
final class CreateResponseChoiceImage implements ResponseContract
17+
{
18+
/**
19+
* @use ArrayAccessible<CreateResponseChoiceImageType>
20+
*/
21+
use ArrayAccessible;
22+
23+
use Fakeable;
24+
25+
/**
26+
* @param array{url: string, detail: string} $imageUrl
27+
*/
28+
private function __construct(
29+
public readonly array $imageUrl,
30+
public readonly int $index,
31+
public readonly string $type,
32+
) {}
33+
34+
/**
35+
* @param CreateResponseChoiceImageType $attributes
36+
*/
37+
public static function from(array $attributes): self
38+
{
39+
return new self(
40+
imageUrl: $attributes['image_url'],
41+
index: $attributes['index'],
42+
type: $attributes['type'],
43+
);
44+
}
45+
46+
/**
47+
* {@inheritDoc}
48+
*/
49+
public function toArray(): array
50+
{
51+
return [
52+
'image_url' => $this->imageUrl,
53+
'index' => $this->index,
54+
'type' => $this->type,
55+
];
56+
}
57+
}

src/Responses/Chat/CreateResponseMessage.php

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66

77
/**
88
* @phpstan-import-type CreateResponseChoiceAudioType from CreateResponseChoiceAudio
9+
* @phpstan-import-type CreateResponseChoiceImageType from CreateResponseChoiceImage
910
*/
1011
final class CreateResponseMessage
1112
{
1213
/**
1314
* @param array<int, CreateResponseToolCall> $toolCalls
1415
* @param array<int, CreateResponseChoiceAnnotations> $annotations
16+
* @param array<int, CreateResponseChoiceImage>|null $images
1517
*/
1618
private function __construct(
1719
public readonly string $role,
@@ -20,10 +22,11 @@ private function __construct(
2022
public readonly array $toolCalls,
2123
public readonly ?CreateResponseFunctionCall $functionCall,
2224
public readonly ?CreateResponseChoiceAudio $audio = null,
25+
public readonly ?array $images = null,
2326
) {}
2427

2528
/**
26-
* @param array{role: string, content: ?string, annotations?: array<int, array{type: string, url_citation: array{start_index: int, end_index: int, title: string, url: string}}>, function_call: ?array{name: string, arguments: string}, tool_calls: ?array<int, array{id: string, type: string, function: array{name: string, arguments: string}}>, audio?: CreateResponseChoiceAudioType} $attributes
29+
* @param array{role: string, content: ?string, annotations?: array<int, array{type: string, url_citation: array{start_index: int, end_index: int, title: string, url: string}}>, function_call?: array{name: string, arguments: string}, tool_calls?: array<int, array{id: string, type: string, function: array{name: string, arguments: string}}>, audio?: CreateResponseChoiceAudioType, images?: array<int, CreateResponseChoiceImageType>} $attributes
2730
*/
2831
public static function from(array $attributes): self
2932
{
@@ -35,18 +38,23 @@ public static function from(array $attributes): self
3538
$result,
3639
), $attributes['annotations'] ?? []);
3740

41+
$images = isset($attributes['images'])
42+
? array_map(fn (array $result): CreateResponseChoiceImage => CreateResponseChoiceImage::from($result), $attributes['images'])
43+
: null;
44+
3845
return new self(
39-
$attributes['role'],
40-
$attributes['content'] ?? null,
41-
$annotations,
42-
$toolCalls,
43-
isset($attributes['function_call']) ? CreateResponseFunctionCall::from($attributes['function_call']) : null,
44-
isset($attributes['audio']) ? CreateResponseChoiceAudio::from($attributes['audio']) : null,
46+
role: $attributes['role'],
47+
content: $attributes['content'] ?? null,
48+
annotations: $annotations,
49+
toolCalls: $toolCalls,
50+
functionCall: isset($attributes['function_call']) ? CreateResponseFunctionCall::from($attributes['function_call']) : null,
51+
audio: isset($attributes['audio']) ? CreateResponseChoiceAudio::from($attributes['audio']) : null,
52+
images: $images,
4553
);
4654
}
4755

4856
/**
49-
* @return array{role: string, content: string|null, annotations?: array<int, array{type: string, url_citation: array{start_index: int, end_index: int, title: string, url: string}}>, function_call?: array{name: string, arguments: string}, tool_calls?: array<int, array{id: string, type: string, function: array{name: string, arguments: string}}>, audio?: CreateResponseChoiceAudioType}
57+
* @return array{role: string, content: string|null, annotations?: array<int, array{type: string, url_citation: array{start_index: int, end_index: int, title: string, url: string}}>, function_call?: array{name: string, arguments: string}, tool_calls?: array<int, array{id: string, type: string, function: array{name: string, arguments: string}}>, audio?: CreateResponseChoiceAudioType, images?: array<int, CreateResponseChoiceImageType>}
5058
*/
5159
public function toArray(): array
5260
{
@@ -71,6 +79,10 @@ public function toArray(): array
7179
$data['audio'] = $this->audio->toArray();
7280
}
7381

82+
if ($this->images !== null && $this->images !== []) {
83+
$data['images'] = array_map(fn (CreateResponseChoiceImage $image): array => $image->toArray(), $this->images);
84+
}
85+
7486
return $data;
7587
}
7688
}

tests/Fixtures/Chat.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,47 @@ function chatCompletionOpenRouter(): array
6666
];
6767
}
6868

69+
/**
70+
* @return array<string, mixed>
71+
*/
72+
function chatCompletionLiteLlmImage(): array
73+
{
74+
return [
75+
'id' => 'chatcmpl-123',
76+
'created' => 1700000000,
77+
'model' => 'litellm/gpt-4o-vision-preview',
78+
'object' => 'chat.completion',
79+
'choices' => [
80+
[
81+
'finish_reason' => 'stop',
82+
'index' => 0,
83+
'message' => [
84+
'role' => 'assistant',
85+
'images' => [
86+
[
87+
'image_url' => [
88+
'url' => 'data:image/png;base64,xxx',
89+
'detail' => 'auto',
90+
],
91+
'index' => 0,
92+
'type' => 'image_url',
93+
],
94+
],
95+
],
96+
'thinking_blocks' => [],
97+
],
98+
],
99+
'usage' => [
100+
'prompt_tokens' => 21,
101+
'completion_tokens' => 36,
102+
'total_tokens' => 57,
103+
'prompt_tokens_details' => [
104+
'cached_tokens' => 0,
105+
],
106+
],
107+
];
108+
}
109+
69110
/**
70111
* @return array<string, mixed>
71112
*/

tests/Responses/Chat/CreateResponse.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,21 @@
228228
->meta()->toBeInstanceOf(MetaInformation::class);
229229
});
230230

231+
test('from (LitemLLM)', function () {
232+
$completion = CreateResponse::from(chatCompletionLiteLlmImage(), meta());
233+
234+
expect($completion)
235+
->toBeInstanceOf(CreateResponse::class)
236+
->id->toBe('chatcmpl-123')
237+
->object->toBe('chat.completion')
238+
->created->toBe(1700000000)
239+
->model->toBe('litellm/gpt-4o-vision-preview')
240+
->systemFingerprint->toBeNull()
241+
->choices->toBeArray()->toHaveCount(1)
242+
->choices->each->toBeInstanceOf(CreateResponseChoice::class)
243+
->usage->toBeInstanceOf(CreateResponseUsage::class);
244+
});
245+
231246
test('from (OpenRouter OpenAI)', function () {
232247
$completion = CreateResponse::from(chatCompletionOpenRouterOpenAI(), meta());
233248

tests/Responses/Chat/CreateResponseUsage.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@
2626
->completionTokensDetails->toBeNull();
2727
});
2828

29+
test('from (LiteLLM)', function () {
30+
$result = CreateResponseUsage::from(chatCompletionLiteLlmImage()['usage']);
31+
32+
expect($result)
33+
->promptTokens->toBe(21)
34+
->completionTokens->toBe(36)
35+
->totalTokens->toBe(57)
36+
->promptTokensDetails->toBeInstanceOf(CreateResponseUsagePromptTokensDetails::class)
37+
->completionTokensDetails->toBeNull();
38+
});
39+
2940
test('from (OpenRouter OpenAI)', function () {
3041
$result = CreateResponseUsage::from(chatCompletionOpenRouterOpenAI()['usage']);
3142

0 commit comments

Comments
 (0)