Skip to content

Commit 53dd780

Browse files
committed
-
1 parent 231db84 commit 53dd780

File tree

6 files changed

+243
-4
lines changed

6 files changed

+243
-4
lines changed

examples/deepseek/chat.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@
2323
);
2424
$result = $platform->invoke('deepseek-chat', $messages);
2525

26-
echo $result->getResult()->getContent().\PHP_EOL;
26+
echo $result->getContent().\PHP_EOL;

examples/deepseek/reason.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,4 @@
2323
);
2424
$result = $platform->invoke('deepseek-reasoner', $messages);
2525

26-
echo $result->getResult()->getContent().\PHP_EOL;
26+
echo $result->getContent().\PHP_EOL;

examples/deepseek/stream.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
'stream' => true, // enable streaming of response text
2626
]);
2727

28-
foreach ($result->getResult()->getContent() as $word) {
28+
foreach ($result->getContent() as $word) {
2929
echo $word;
3030
}
3131
echo \PHP_EOL;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Bridge\DeepSeek\Chat;
13+
14+
use Symfony\AI\Platform\Bridge\DeepSeek\DeepSeek;
15+
use Symfony\AI\Platform\Model;
16+
use Symfony\AI\Platform\ModelClientInterface;
17+
use Symfony\AI\Platform\Result\RawHttpResult;
18+
use Symfony\Component\HttpClient\EventSourceHttpClient;
19+
use Symfony\Contracts\HttpClient\HttpClientInterface;
20+
21+
/**
22+
* @author Oskar Stark <oskarstark@googlemail.com>
23+
*/
24+
final readonly class ModelClient implements ModelClientInterface
25+
{
26+
private EventSourceHttpClient $httpClient;
27+
28+
public function __construct(
29+
HttpClientInterface $httpClient,
30+
#[\SensitiveParameter] private string $apiKey,
31+
) {
32+
$this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
33+
}
34+
35+
public function supports(Model $model): bool
36+
{
37+
return $model instanceof DeepSeek;
38+
}
39+
40+
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
41+
{
42+
return new RawHttpResult($this->httpClient->request('POST', 'https://api.deepseek.com/chat/completions', [
43+
'auth_bearer' => $this->apiKey,
44+
'json' => array_merge($options, $payload),
45+
]));
46+
}
47+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Bridge\DeepSeek\Chat;
13+
14+
use Symfony\AI\Platform\Bridge\DeepSeek\DeepSeek;
15+
use Symfony\AI\Platform\Exception\ContentFilterException;
16+
use Symfony\AI\Platform\Exception\InvalidRequestException;
17+
use Symfony\AI\Platform\Exception\RuntimeException;
18+
use Symfony\AI\Platform\Model;
19+
use Symfony\AI\Platform\Result\ChoiceResult;
20+
use Symfony\AI\Platform\Result\RawHttpResult;
21+
use Symfony\AI\Platform\Result\RawResultInterface;
22+
use Symfony\AI\Platform\Result\ResultInterface;
23+
use Symfony\AI\Platform\Result\StreamResult;
24+
use Symfony\AI\Platform\Result\TextResult;
25+
use Symfony\AI\Platform\Result\ToolCall;
26+
use Symfony\AI\Platform\Result\ToolCallResult;
27+
use Symfony\AI\Platform\ResultConverterInterface;
28+
use Symfony\Component\HttpClient\Chunk\ServerSentEvent;
29+
use Symfony\Component\HttpClient\EventSourceHttpClient;
30+
use Symfony\Component\HttpClient\Exception\JsonException;
31+
use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse;
32+
33+
/**
34+
* @author Oskar Stark <oskarstark@googlemail.com>
35+
*/
36+
final class ResultConverter implements ResultConverterInterface
37+
{
38+
public function supports(Model $model): bool
39+
{
40+
return $model instanceof DeepSeek;
41+
}
42+
43+
public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface
44+
{
45+
if ($options['stream'] ?? false) {
46+
return new StreamResult($this->convertStream($result->getObject()));
47+
}
48+
49+
$data = $result->getData();
50+
51+
if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) {
52+
throw new ContentFilterException($data['error']['message']);
53+
}
54+
55+
if (isset($data['error']['code'])) {
56+
match ($data['error']['code']) {
57+
'content_filter' => throw new ContentFilterException($data['error']['message']),
58+
'invalid_request_error' => throw new InvalidRequestException($data['error']['message']),
59+
};
60+
}
61+
62+
$choices = array_map($this->convertChoice(...), $data['choices']);
63+
64+
return 1 === \count($choices) ? $choices[0] : new ChoiceResult(...$choices);
65+
}
66+
67+
private function convertStream(HttpResponse $result): \Generator
68+
{
69+
$toolCalls = [];
70+
foreach ((new EventSourceHttpClient())->stream($result) as $chunk) {
71+
if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) {
72+
continue;
73+
}
74+
75+
try {
76+
$data = $chunk->getArrayData();
77+
} catch (JsonException) {
78+
// try catch only needed for Symfony 6.4
79+
continue;
80+
}
81+
82+
if ($this->streamIsToolCall($data)) {
83+
$toolCalls = $this->convertStreamToToolCalls($toolCalls, $data);
84+
}
85+
86+
if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) {
87+
yield new ToolCallResult(...array_map($this->convertToolCall(...), $toolCalls));
88+
}
89+
90+
if (!isset($data['choices'][0]['delta']['content'])) {
91+
continue;
92+
}
93+
94+
yield $data['choices'][0]['delta']['content'];
95+
}
96+
}
97+
98+
/**
99+
* @param array<string, mixed> $toolCalls
100+
* @param array<string, mixed> $data
101+
*
102+
* @return array<string, mixed>
103+
*/
104+
private function convertStreamToToolCalls(array $toolCalls, array $data): array
105+
{
106+
if (!isset($data['choices'][0]['delta']['tool_calls'])) {
107+
return $toolCalls;
108+
}
109+
110+
foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) {
111+
if (isset($toolCall['id'])) {
112+
// initialize tool call
113+
$toolCalls[$i] = [
114+
'id' => $toolCall['id'],
115+
'function' => $toolCall['function'],
116+
];
117+
continue;
118+
}
119+
120+
// add arguments delta to tool call
121+
$toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments'];
122+
}
123+
124+
return $toolCalls;
125+
}
126+
127+
/**
128+
* @param array<string, mixed> $data
129+
*/
130+
private function streamIsToolCall(array $data): bool
131+
{
132+
return isset($data['choices'][0]['delta']['tool_calls']);
133+
}
134+
135+
/**
136+
* @param array<string, mixed> $data
137+
*/
138+
private function isToolCallsStreamFinished(array $data): bool
139+
{
140+
return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason'];
141+
}
142+
143+
/**
144+
* @param array{
145+
* index: int,
146+
* message: array{
147+
* role: 'assistant',
148+
* content: ?string,
149+
* tool_calls: array{
150+
* id: string,
151+
* type: 'function',
152+
* function: array{
153+
* name: string,
154+
* arguments: string
155+
* },
156+
* },
157+
* refusal: ?mixed
158+
* },
159+
* logprobs: string,
160+
* finish_reason: 'stop'|'length'|'tool_calls'|'content_filter',
161+
* } $choice
162+
*/
163+
private function convertChoice(array $choice): ToolCallResult|TextResult
164+
{
165+
if ('tool_calls' === $choice['finish_reason']) {
166+
return new ToolCallResult(...array_map([$this, 'convertToolCall'], $choice['message']['tool_calls']));
167+
}
168+
169+
if (\in_array($choice['finish_reason'], ['stop', 'length'], true)) {
170+
return new TextResult($choice['message']['content']);
171+
}
172+
173+
throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason']));
174+
}
175+
176+
/**
177+
* @param array{
178+
* id: string,
179+
* type: 'function',
180+
* function: array{
181+
* name: string,
182+
* arguments: string
183+
* }
184+
* } $toolCall
185+
*/
186+
private function convertToolCall(array $toolCall): ToolCall
187+
{
188+
$arguments = json_decode($toolCall['function']['arguments'], true, \JSON_THROW_ON_ERROR);
189+
190+
return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments);
191+
}
192+
}

src/platform/src/Bridge/DeepSeek/DeepSeek.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ final class DeepSeek extends Model
1919
public const CHAT = 'deepseek-chat';
2020
public const REASONER = 'deepseek-reasoner';
2121

22-
public function __construct(string $name = self::CHAT, array $options = [])
22+
public function __construct(string $name, array $options = [])
2323
{
2424
$capabilities = [
2525
Capability::INPUT_MESSAGES,

0 commit comments

Comments
 (0)