Skip to content

Commit 7a3758e

Browse files
committed
feat: add ResponseContract handling
1 parent 9ae7051 commit 7a3758e

16 files changed

+807
-4
lines changed

src/Platform/Bridge/Mistral/PlatformFactory.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Embeddings\ModelClient as EmbeddingsModelClient;
99
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Embeddings\ResponseConverter as EmbeddingsResponseConverter;
1010
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Llm\ModelClient as MistralModelClient;
11-
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Llm\ResponseConverter as MistralResponseConverter;
11+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\ResponseContract\MistralResponseParser;
12+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\ResponseContract\MistralStreamParser;
1213
use PhpLlm\LlmChain\Platform\Contract;
1314
use PhpLlm\LlmChain\Platform\Platform;
15+
use PhpLlm\LlmChain\Platform\ResponseContract;
1416
use Symfony\Component\HttpClient\EventSourceHttpClient;
1517
use Symfony\Contracts\HttpClient\HttpClientInterface;
1618

@@ -28,7 +30,13 @@ public static function create(
2830

2931
return new Platform(
3032
[new EmbeddingsModelClient($httpClient, $apiKey), new MistralModelClient($httpClient, $apiKey)],
31-
[new EmbeddingsResponseConverter(), new MistralResponseConverter()],
33+
[
34+
new EmbeddingsResponseConverter(),
35+
ResponseContract::create(
36+
new MistralResponseParser(),
37+
new MistralStreamParser(),
38+
)->asConverter(),
39+
],
3240
Contract::create(new ToolNormalizer()),
3341
);
3442
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Platform\Bridge\Mistral\ResponseContract;
6+
7+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Mistral;
8+
use PhpLlm\LlmChain\Platform\Contract\Denormalizer\ModelContractDenormalizer;
9+
use PhpLlm\LlmChain\Platform\Model;
10+
use PhpLlm\LlmChain\Platform\Response\DTO\ParsedChoice;
11+
use PhpLlm\LlmChain\Platform\Response\DTO\ParsedResponse;
12+
use PhpLlm\LlmChain\Platform\Response\DTO\ParsedToolCall;
13+
14+
final class MistralResponseParser extends ModelContractDenormalizer
15+
{
16+
protected function supportsModel(Model $model): bool
17+
{
18+
return $model instanceof Mistral;
19+
}
20+
21+
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ParsedResponse
22+
{
23+
if (!isset($data['choices'])) {
24+
throw new \RuntimeException('Invalid Mistral response structure: missing choices');
25+
}
26+
27+
$choices = array_map([$this, 'parseChoice'], $data['choices']);
28+
29+
// For single choice responses, extract to top level
30+
if (1 === \count($choices)) {
31+
return new ParsedResponse(
32+
textContent: $choices[0]->content,
33+
toolCalls: $choices[0]->toolCalls,
34+
finishReason: $choices[0]->finishReason,
35+
choices: $choices,
36+
metadata: $this->extractMetadata($data),
37+
);
38+
}
39+
40+
return new ParsedResponse(
41+
choices: $choices,
42+
metadata: $this->extractMetadata($data),
43+
);
44+
}
45+
46+
/**
47+
* @param array<string, mixed> $choice
48+
*/
49+
private function parseChoice(array $choice): ParsedChoice
50+
{
51+
$message = $choice['message'] ?? [];
52+
53+
return new ParsedChoice(
54+
content: $message['content'] ?? null,
55+
toolCalls: array_map([$this, 'parseToolCall'], $message['tool_calls'] ?? []),
56+
finishReason: $choice['finish_reason'] ?? null,
57+
index: $choice['index'] ?? 0,
58+
);
59+
}
60+
61+
/**
62+
* @param array<string, mixed> $toolCall
63+
*/
64+
private function parseToolCall(array $toolCall): ParsedToolCall
65+
{
66+
return new ParsedToolCall(
67+
id: $toolCall['id'],
68+
name: $toolCall['function']['name'],
69+
arguments: json_decode($toolCall['function']['arguments'], true, flags: \JSON_THROW_ON_ERROR),
70+
type: $toolCall['type'] ?? 'function',
71+
);
72+
}
73+
74+
/**
75+
* @param array<string, mixed> $data
76+
*
77+
* @return array<string, mixed>
78+
*/
79+
private function extractMetadata(array $data): array
80+
{
81+
$metadata = [];
82+
83+
if (isset($data['usage'])) {
84+
$metadata['usage'] = $data['usage'];
85+
}
86+
87+
if (isset($data['model'])) {
88+
$metadata['model'] = $data['model'];
89+
}
90+
91+
if (isset($data['id'])) {
92+
$metadata['id'] = $data['id'];
93+
}
94+
95+
return $metadata;
96+
}
97+
98+
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
99+
{
100+
return ParsedResponse::class === $type && parent::supportsDenormalization($data, $type, $format, $context);
101+
}
102+
103+
public function getSupportedTypes(?string $format): array
104+
{
105+
return [ParsedResponse::class => false];
106+
}
107+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Platform\Bridge\Mistral\ResponseContract;
6+
7+
use PhpLlm\LlmChain\Platform\Bridge\Mistral\Mistral;
8+
use PhpLlm\LlmChain\Platform\Contract\Denormalizer\ModelContractDenormalizer;
9+
use PhpLlm\LlmChain\Platform\Model;
10+
use PhpLlm\LlmChain\Platform\Response\DTO\StreamChunk;
11+
12+
final class MistralStreamParser extends ModelContractDenormalizer
13+
{
14+
protected function supportsModel(Model $model): bool
15+
{
16+
return $model instanceof Mistral;
17+
}
18+
19+
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): StreamChunk
20+
{
21+
$choice = $data['choices'][0] ?? [];
22+
$delta = $choice['delta'] ?? [];
23+
24+
return new StreamChunk(
25+
textDelta: $delta['content'] ?? null,
26+
toolCallDeltas: $this->parseToolCallDeltas($delta['tool_calls'] ?? []),
27+
finishReason: $choice['finish_reason'] ?? null,
28+
isDone: false, // Mistral uses '[DONE]' signal which is handled by the stream denormalizer
29+
);
30+
}
31+
32+
/**
33+
* @param array<int, array{id?: string, function?: array{name?: string, arguments?: string}}> $toolCalls
34+
*
35+
* @return array<int, array{id?: string, name?: string, arguments?: string}>
36+
*/
37+
private function parseToolCallDeltas(array $toolCalls): array
38+
{
39+
$deltas = [];
40+
41+
foreach ($toolCalls as $i => $toolCall) {
42+
$delta = [];
43+
44+
if (isset($toolCall['id'])) {
45+
$delta['id'] = $toolCall['id'];
46+
}
47+
48+
if (isset($toolCall['function']['name'])) {
49+
$delta['name'] = $toolCall['function']['name'];
50+
}
51+
52+
if (isset($toolCall['function']['arguments'])) {
53+
$delta['arguments'] = $toolCall['function']['arguments'];
54+
}
55+
56+
if (!empty($delta)) {
57+
$deltas[$i] = $delta;
58+
}
59+
}
60+
61+
return $deltas;
62+
}
63+
64+
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
65+
{
66+
return StreamChunk::class === $type && parent::supportsDenormalization($data, $type, $format, $context);
67+
}
68+
69+
public function getSupportedTypes(?string $format): array
70+
{
71+
return [StreamChunk::class => false];
72+
}
73+
}

src/Platform/Bridge/OpenAI/PlatformFactory.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings\ModelClient as EmbeddingsModelClient;
99
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Embeddings\ResponseConverter as EmbeddingsResponseConverter;
1010
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT\ModelClient as GPTModelClient;
11-
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT\ResponseConverter as GPTResponseConverter;
11+
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\ResponseContract\OpenAIResponseParser;
12+
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\ResponseContract\OpenAIStreamParser;
1213
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Whisper\AudioNormalizer;
1314
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Whisper\ModelClient as WhisperModelClient;
1415
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\Whisper\ResponseConverter as WhisperResponseConverter;
1516
use PhpLlm\LlmChain\Platform\Contract;
1617
use PhpLlm\LlmChain\Platform\Platform;
18+
use PhpLlm\LlmChain\Platform\ResponseContract;
1719
use Symfony\Component\HttpClient\EventSourceHttpClient;
1820
use Symfony\Contracts\HttpClient\HttpClientInterface;
1921

@@ -39,7 +41,10 @@ public static function create(
3941
new WhisperModelClient($httpClient, $apiKey),
4042
],
4143
[
42-
new GPTResponseConverter(),
44+
ResponseContract::create(
45+
new OpenAIResponseParser(),
46+
new OpenAIStreamParser(),
47+
)->asConverter(),
4348
new EmbeddingsResponseConverter(),
4449
$dallEModelClient,
4550
new WhisperResponseConverter(),
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Platform\Bridge\OpenAI\ResponseContract;
6+
7+
use PhpLlm\LlmChain\Platform\Bridge\OpenAI\GPT;
8+
use PhpLlm\LlmChain\Platform\Contract\Denormalizer\ModelContractDenormalizer;
9+
use PhpLlm\LlmChain\Platform\Model;
10+
use PhpLlm\LlmChain\Platform\Response\DTO\ParsedChoice;
11+
use PhpLlm\LlmChain\Platform\Response\DTO\ParsedResponse;
12+
use PhpLlm\LlmChain\Platform\Response\DTO\ParsedToolCall;
13+
14+
final class OpenAIResponseParser extends ModelContractDenormalizer
15+
{
16+
protected function supportsModel(Model $model): bool
17+
{
18+
return $model instanceof GPT;
19+
}
20+
21+
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ParsedResponse
22+
{
23+
if (!isset($data['choices'])) {
24+
throw new \RuntimeException('Invalid OpenAI response structure: missing choices');
25+
}
26+
27+
$choices = array_map([$this, 'parseChoice'], $data['choices']);
28+
29+
// For single choice responses, extract to top level
30+
if (1 === \count($choices)) {
31+
return new ParsedResponse(
32+
textContent: $choices[0]->content,
33+
toolCalls: $choices[0]->toolCalls,
34+
finishReason: $choices[0]->finishReason,
35+
choices: $choices,
36+
metadata: $this->extractMetadata($data),
37+
);
38+
}
39+
40+
return new ParsedResponse(
41+
choices: $choices,
42+
metadata: $this->extractMetadata($data),
43+
);
44+
}
45+
46+
/**
47+
* @param array<string, mixed> $choice
48+
*/
49+
private function parseChoice(array $choice): ParsedChoice
50+
{
51+
$message = $choice['message'] ?? [];
52+
53+
return new ParsedChoice(
54+
content: $message['content'] ?? null,
55+
toolCalls: array_map([$this, 'parseToolCall'], $message['tool_calls'] ?? []),
56+
finishReason: $choice['finish_reason'] ?? null,
57+
index: $choice['index'] ?? 0,
58+
);
59+
}
60+
61+
/**
62+
* @param array<string, mixed> $toolCall
63+
*/
64+
private function parseToolCall(array $toolCall): ParsedToolCall
65+
{
66+
return new ParsedToolCall(
67+
id: $toolCall['id'],
68+
name: $toolCall['function']['name'],
69+
arguments: json_decode($toolCall['function']['arguments'], true, flags: \JSON_THROW_ON_ERROR),
70+
type: $toolCall['type'] ?? 'function',
71+
);
72+
}
73+
74+
/**
75+
* @param array<string, mixed> $data
76+
*
77+
* @return array<string, mixed>
78+
*/
79+
private function extractMetadata(array $data): array
80+
{
81+
$metadata = [];
82+
83+
if (isset($data['usage'])) {
84+
$metadata['usage'] = $data['usage'];
85+
}
86+
87+
if (isset($data['model'])) {
88+
$metadata['model'] = $data['model'];
89+
}
90+
91+
if (isset($data['id'])) {
92+
$metadata['id'] = $data['id'];
93+
}
94+
95+
return $metadata;
96+
}
97+
98+
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
99+
{
100+
return ParsedResponse::class === $type && parent::supportsDenormalization($data, $type, $format, $context);
101+
}
102+
103+
public function getSupportedTypes(?string $format): array
104+
{
105+
return [ParsedResponse::class => false];
106+
}
107+
}

0 commit comments

Comments
 (0)