diff --git a/src/Resources/Chat.php b/src/Resources/Chat.php index 2277764b..dfabeff1 100644 --- a/src/Resources/Chat.php +++ b/src/Resources/Chat.php @@ -29,7 +29,7 @@ public function create(array $parameters): CreateResponse $payload = Payload::create('chat/completions', $parameters); - /** @var Response}, logprobs: ?array{content: ?array}>}, finish_reason: string|null}>, usage: array{prompt_tokens: int, completion_tokens: int|null, total_tokens: int}}> $response */ + /** @var Response}, logprobs: ?array{content: ?array}>}, finish_reason: string|null}>, usage: array{prompt_tokens: int, completion_tokens: int|null, total_tokens: int}}> $response */ $response = $this->transporter->requestObject($payload); return CreateResponse::from($response->data(), $response->meta()); diff --git a/src/Responses/Chat/CreateResponse.php b/src/Responses/Chat/CreateResponse.php index b6492c15..b04f3b0a 100644 --- a/src/Responses/Chat/CreateResponse.php +++ b/src/Responses/Chat/CreateResponse.php @@ -41,7 +41,7 @@ private function __construct( /** * Acts as static factory, and returns a new Response instance. * - * @param array{id?: string, object: string, created: int, model: string, system_fingerprint?: string, choices: array, function_call: ?array{name: string, arguments: string}, tool_calls: ?array}, logprobs: ?array{content: ?array}>}, 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 + * @param array{id?: string, object: string, created: int, model: string, system_fingerprint?: string, choices: array, function_call?: array{name: string, arguments: string}, tool_calls?: array}, logprobs: ?array{content: ?array}>}, 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 */ public static function from(array $attributes, MetaInformation $meta): self { diff --git a/src/Responses/Chat/CreateResponseChoice.php b/src/Responses/Chat/CreateResponseChoice.php index c5097e14..0ca94630 100644 --- a/src/Responses/Chat/CreateResponseChoice.php +++ b/src/Responses/Chat/CreateResponseChoice.php @@ -14,7 +14,7 @@ private function __construct( ) {} /** - * @param array{index: int, message: array{role: string, content: ?string, annotations?: array, function_call: ?array{name: string, arguments: string}, tool_calls: ?array} ,logprobs?: ?array{content: ?array}>}, finish_reason: string|null} $attributes + * @param array{index: int, message: array{role: string, content: ?string, annotations?: array, function_call?: array{name: string, arguments: string}, tool_calls?: array} ,logprobs?: ?array{content: ?array}>}, finish_reason: string|null} $attributes */ public static function from(array $attributes): self { diff --git a/src/Responses/Chat/CreateResponseChoiceImage.php b/src/Responses/Chat/CreateResponseChoiceImage.php new file mode 100644 index 00000000..0d92a2e0 --- /dev/null +++ b/src/Responses/Chat/CreateResponseChoiceImage.php @@ -0,0 +1,57 @@ + + */ +final class CreateResponseChoiceImage implements ResponseContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + + /** + * @param array{url: string, detail: string} $imageUrl + */ + private function __construct( + public readonly array $imageUrl, + public readonly int $index, + public readonly string $type, + ) {} + + /** + * @param CreateResponseChoiceImageType $attributes + */ + public static function from(array $attributes): self + { + return new self( + imageUrl: $attributes['image_url'], + index: $attributes['index'], + type: $attributes['type'], + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'image_url' => $this->imageUrl, + 'index' => $this->index, + 'type' => $this->type, + ]; + } +} diff --git a/src/Responses/Chat/CreateResponseMessage.php b/src/Responses/Chat/CreateResponseMessage.php index dc347f7f..71aa8e79 100644 --- a/src/Responses/Chat/CreateResponseMessage.php +++ b/src/Responses/Chat/CreateResponseMessage.php @@ -6,12 +6,14 @@ /** * @phpstan-import-type CreateResponseChoiceAudioType from CreateResponseChoiceAudio + * @phpstan-import-type CreateResponseChoiceImageType from CreateResponseChoiceImage */ final class CreateResponseMessage { /** * @param array $toolCalls * @param array $annotations + * @param array|null $images */ private function __construct( public readonly string $role, @@ -20,10 +22,11 @@ private function __construct( public readonly array $toolCalls, public readonly ?CreateResponseFunctionCall $functionCall, public readonly ?CreateResponseChoiceAudio $audio = null, + public readonly ?array $images = null, ) {} /** - * @param array{role: string, content: ?string, annotations?: array, function_call: ?array{name: string, arguments: string}, tool_calls: ?array, audio?: CreateResponseChoiceAudioType} $attributes + * @param array{role: string, content: ?string, annotations?: array, function_call?: array{name: string, arguments: string}, tool_calls?: array, audio?: CreateResponseChoiceAudioType, images?: array} $attributes */ public static function from(array $attributes): self { @@ -35,18 +38,23 @@ public static function from(array $attributes): self $result, ), $attributes['annotations'] ?? []); + $images = isset($attributes['images']) + ? array_map(fn (array $result): CreateResponseChoiceImage => CreateResponseChoiceImage::from($result), $attributes['images']) + : null; + return new self( - $attributes['role'], - $attributes['content'] ?? null, - $annotations, - $toolCalls, - isset($attributes['function_call']) ? CreateResponseFunctionCall::from($attributes['function_call']) : null, - isset($attributes['audio']) ? CreateResponseChoiceAudio::from($attributes['audio']) : null, + role: $attributes['role'], + content: $attributes['content'] ?? null, + annotations: $annotations, + toolCalls: $toolCalls, + functionCall: isset($attributes['function_call']) ? CreateResponseFunctionCall::from($attributes['function_call']) : null, + audio: isset($attributes['audio']) ? CreateResponseChoiceAudio::from($attributes['audio']) : null, + images: $images, ); } /** - * @return array{role: string, content: string|null, annotations?: array, function_call?: array{name: string, arguments: string}, tool_calls?: array, audio?: CreateResponseChoiceAudioType} + * @return array{role: string, content: string|null, annotations?: array, function_call?: array{name: string, arguments: string}, tool_calls?: array, audio?: CreateResponseChoiceAudioType, images?: array} */ public function toArray(): array { @@ -71,6 +79,10 @@ public function toArray(): array $data['audio'] = $this->audio->toArray(); } + if ($this->images !== null && $this->images !== []) { + $data['images'] = array_map(fn (CreateResponseChoiceImage $image): array => $image->toArray(), $this->images); + } + return $data; } } diff --git a/tests/Fixtures/Chat.php b/tests/Fixtures/Chat.php index 98bff880..4e8dd51b 100644 --- a/tests/Fixtures/Chat.php +++ b/tests/Fixtures/Chat.php @@ -66,6 +66,47 @@ function chatCompletionOpenRouter(): array ]; } +/** + * @return array + */ +function chatCompletionLiteLlmImage(): array +{ + return [ + 'id' => 'chatcmpl-123', + 'created' => 1700000000, + 'model' => 'litellm/gpt-4o-vision-preview', + 'object' => 'chat.completion', + 'choices' => [ + [ + 'finish_reason' => 'stop', + 'index' => 0, + 'message' => [ + 'role' => 'assistant', + 'images' => [ + [ + 'image_url' => [ + 'url' => 'data:image/png;base64,xxx', + 'detail' => 'auto', + ], + 'index' => 0, + 'type' => 'image_url', + ], + ], + ], + 'thinking_blocks' => [], + ], + ], + 'usage' => [ + 'prompt_tokens' => 21, + 'completion_tokens' => 36, + 'total_tokens' => 57, + 'prompt_tokens_details' => [ + 'cached_tokens' => 0, + ], + ], + ]; +} + /** * @return array */ diff --git a/tests/Responses/Chat/CreateResponse.php b/tests/Responses/Chat/CreateResponse.php index 5eecc2e0..4a1b60ef 100644 --- a/tests/Responses/Chat/CreateResponse.php +++ b/tests/Responses/Chat/CreateResponse.php @@ -228,6 +228,21 @@ ->meta()->toBeInstanceOf(MetaInformation::class); }); +test('from (LitemLLM)', function () { + $completion = CreateResponse::from(chatCompletionLiteLlmImage(), meta()); + + expect($completion) + ->toBeInstanceOf(CreateResponse::class) + ->id->toBe('chatcmpl-123') + ->object->toBe('chat.completion') + ->created->toBe(1700000000) + ->model->toBe('litellm/gpt-4o-vision-preview') + ->systemFingerprint->toBeNull() + ->choices->toBeArray()->toHaveCount(1) + ->choices->each->toBeInstanceOf(CreateResponseChoice::class) + ->usage->toBeInstanceOf(CreateResponseUsage::class); +}); + test('from (OpenRouter OpenAI)', function () { $completion = CreateResponse::from(chatCompletionOpenRouterOpenAI(), meta()); diff --git a/tests/Responses/Chat/CreateResponseUsage.php b/tests/Responses/Chat/CreateResponseUsage.php index 06f298a5..0932294d 100644 --- a/tests/Responses/Chat/CreateResponseUsage.php +++ b/tests/Responses/Chat/CreateResponseUsage.php @@ -26,6 +26,17 @@ ->completionTokensDetails->toBeNull(); }); +test('from (LiteLLM)', function () { + $result = CreateResponseUsage::from(chatCompletionLiteLlmImage()['usage']); + + expect($result) + ->promptTokens->toBe(21) + ->completionTokens->toBe(36) + ->totalTokens->toBe(57) + ->promptTokensDetails->toBeInstanceOf(CreateResponseUsagePromptTokensDetails::class) + ->completionTokensDetails->toBeNull(); +}); + test('from (OpenRouter OpenAI)', function () { $result = CreateResponseUsage::from(chatCompletionOpenRouterOpenAI()['usage']);