diff --git a/examples/.env b/examples/.env index 64bde52b1..33a1c39b3 100644 --- a/examples/.env +++ b/examples/.env @@ -153,3 +153,6 @@ WEAVIATE_API_KEY=symfony # For using Scaleway SCALEWAY_SECRET_KEY= + +# For using DeepSeek +DEEPSEEK_API_KEY= diff --git a/examples/deepseek/chat.php b/examples/deepseek/chat.php new file mode 100644 index 000000000..5736f1fc9 --- /dev/null +++ b/examples/deepseek/chat.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\DeepSeek\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('DEEPSEEK_API_KEY'), http_client()); + +$messages = new MessageBag( + Message::forSystem('You are an in-universe Matrix programme, always make hints at the Matrix.'), + Message::ofUser('Yesterday I had a Déjà vu. It is a funny feeling, no?'), +); +$result = $platform->invoke('deepseek-chat', $messages); + +echo $result->asText().\PHP_EOL; diff --git a/examples/deepseek/reason.php b/examples/deepseek/reason.php new file mode 100644 index 000000000..73fe21946 --- /dev/null +++ b/examples/deepseek/reason.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\DeepSeek\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('DEEPSEEK_API_KEY'), http_client()); + +$messages = new MessageBag( + Message::forSystem('You are an elementary school teacher.'), + Message::ofUser('Why can I see the moon at night?'), +); +$result = $platform->invoke('deepseek-reasoner', $messages); + +echo $result->asText().\PHP_EOL; diff --git a/examples/deepseek/stream.php b/examples/deepseek/stream.php new file mode 100644 index 000000000..d6014e41f --- /dev/null +++ b/examples/deepseek/stream.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\DeepSeek\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('DEEPSEEK_API_KEY'), http_client()); + +$messages = new MessageBag( + Message::forSystem('You are a thoughtful philosopher.'), + Message::ofUser('What is the purpose of an ant?'), +); +$result = $platform->invoke('deepseek-chat', $messages, ['stream' => true]); + +print_stream($result); diff --git a/examples/deepseek/structured-output-clock.php b/examples/deepseek/structured-output-clock.php new file mode 100644 index 000000000..22c82f241 --- /dev/null +++ b/examples/deepseek/structured-output-clock.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\StructuredOutput\AgentProcessor as StructuredOutputProcessor; +use Symfony\AI\Agent\Toolbox\AgentProcessor as ToolProcessor; +use Symfony\AI\Agent\Toolbox\Tool\Clock; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Platform\Bridge\DeepSeek\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\Component\Clock\Clock as SymfonyClock; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('DEEPSEEK_API_KEY'), http_client()); + +$clock = new Clock(new SymfonyClock()); +$toolbox = new Toolbox([$clock]); +$toolProcessor = new ToolProcessor($toolbox); +$structuredOutputProcessor = new StructuredOutputProcessor(); +$agent = new Agent($platform, 'deepseek-chat', [$toolProcessor, $structuredOutputProcessor], [$toolProcessor, $structuredOutputProcessor]); + +$messages = new MessageBag( + // for DeepSeek it is *mandatory* to mention JSON anywhere in the prompt when using structured output + Message::forSystem('Respond in JSON as instructed in the response format.'), + Message::ofUser('What date and time is it?') +); +$result = $agent->call($messages, ['response_format' => [ + 'type' => 'json_object', + 'json_object' => [ + 'name' => 'clock', + 'strict' => true, + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'date' => ['type' => 'string', 'description' => 'The current date in the format YYYY-MM-DD.'], + 'time' => ['type' => 'string', 'description' => 'The current time in the format HH:MM:SS.'], + ], + 'required' => ['date', 'time'], + 'additionalProperties' => false, + ], + ], +]]); + +dump($result->getContent()); diff --git a/examples/deepseek/token-metadata.php b/examples/deepseek/token-metadata.php new file mode 100644 index 000000000..5c5fa2ce3 --- /dev/null +++ b/examples/deepseek/token-metadata.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Platform\Bridge\DeepSeek\PlatformFactory; +use Symfony\AI\Platform\Bridge\DeepSeek\TokenOutputProcessor; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('DEEPSEEK_API_KEY'), http_client()); + +$agent = new Agent($platform, 'deepseek-chat', outputProcessors: [new TokenOutputProcessor()]); +$messages = new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +); +$result = $agent->call($messages); + +print_token_usage($result->getMetadata()); diff --git a/examples/deepseek/toolcall-stream.php b/examples/deepseek/toolcall-stream.php new file mode 100644 index 000000000..f404a6e22 --- /dev/null +++ b/examples/deepseek/toolcall-stream.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\Toolbox\AgentProcessor; +use Symfony\AI\Agent\Toolbox\Tool\Wikipedia; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Platform\Bridge\DeepSeek\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('DEEPSEEK_API_KEY'), http_client()); + +$wikipedia = new Wikipedia(http_client()); +$toolbox = new Toolbox([$wikipedia]); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, 'deepseek-chat', [$processor], [$processor]); +$messages = new MessageBag(Message::ofUser(<<call($messages, [ + 'stream' => true, // enable streaming of response text +]); + +foreach ($result->getContent() as $word) { + echo $word; +} + +echo \PHP_EOL; diff --git a/examples/deepseek/toolcall.php b/examples/deepseek/toolcall.php new file mode 100644 index 000000000..767edf7ae --- /dev/null +++ b/examples/deepseek/toolcall.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\Toolbox\AgentProcessor; +use Symfony\AI\Agent\Toolbox\Tool\Clock; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Platform\Bridge\DeepSeek\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('DEEPSEEK_API_KEY'), http_client()); + +$clock = new Clock(); +$toolbox = new Toolbox([$clock]); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, 'deepseek-chat', [$processor], [$processor]); + +$messages = new MessageBag(Message::ofUser('How many days until next Christmas?')); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/src/ai-bundle/config/services.php b/src/ai-bundle/config/services.php index d3614055f..b32b3a548 100644 --- a/src/ai-bundle/config/services.php +++ b/src/ai-bundle/config/services.php @@ -33,6 +33,7 @@ use Symfony\AI\Platform\Bridge\Azure\Meta\ModelCatalog as AzureMetaModelCatalog; use Symfony\AI\Platform\Bridge\Azure\OpenAi\ModelCatalog as AzureOpenAiModelCatalog; use Symfony\AI\Platform\Bridge\Cerebras\ModelCatalog as CerebrasModelCatalog; +use Symfony\AI\Platform\Bridge\DeepSeek\ModelCatalog as DeepSeekModelCatalog; use Symfony\AI\Platform\Bridge\DockerModelRunner\ModelCatalog as DockerModelRunnerModelCatalog; use Symfony\AI\Platform\Bridge\ElevenLabs\ModelCatalog as ElevenLabsModelCatalog; use Symfony\AI\Platform\Bridge\Gemini\Contract\GeminiContract; @@ -89,6 +90,7 @@ ->set('ai.platform.model_catalog.azure.meta', AzureMetaModelCatalog::class) ->set('ai.platform.model_catalog.azure.openai', AzureOpenAiModelCatalog::class) ->set('ai.platform.model_catalog.cerebras', CerebrasModelCatalog::class) + ->set('ai.platform.model_catalog.deepseek', DeepSeekModelCatalog::class) ->set('ai.platform.model_catalog.dockermodelrunner', DockerModelRunnerModelCatalog::class) ->set('ai.platform.model_catalog.elevenlabs', ElevenLabsModelCatalog::class) ->set('ai.platform.model_catalog.gemini', GeminiModelCatalog::class) diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index a77e48fbc..82fe6f448 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -37,6 +37,7 @@ use Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory as AnthropicPlatformFactory; use Symfony\AI\Platform\Bridge\Azure\OpenAi\PlatformFactory as AzureOpenAiPlatformFactory; use Symfony\AI\Platform\Bridge\Cerebras\PlatformFactory as CerebrasPlatformFactory; +use Symfony\AI\Platform\Bridge\DeepSeek\PlatformFactory as DeepSeekPlatformFactory; use Symfony\AI\Platform\Bridge\DockerModelRunner\PlatformFactory as DockerModelRunnerPlatformFactory; use Symfony\AI\Platform\Bridge\ElevenLabs\PlatformFactory as ElevenLabsPlatformFactory; use Symfony\AI\Platform\Bridge\Gemini\PlatformFactory as GeminiPlatformFactory; @@ -461,6 +462,25 @@ private function processPlatformConfig(string $type, array $platform, ContainerB return; } + if ('deepseek' === $type) { + $platformId = 'ai.platform.deepseek'; + $definition = (new Definition(Platform::class)) + ->setFactory(DeepSeekPlatformFactory::class.'::create') + ->setLazy(true) + ->addTag('proxy', ['interface' => PlatformInterface::class]) + ->setArguments([ + $platform['api_key'], + new Reference($platform['http_client'], ContainerInterface::NULL_ON_INVALID_REFERENCE), + new Reference('ai.platform.model_catalog.deepseek'), + new Reference('ai.platform.contract.default'), + ]) + ->addTag('ai.platform', ['name' => 'deepseek']); + + $container->setDefinition($platformId, $definition); + + return; + } + if ('voyage' === $type) { $platformId = 'ai.platform.voyage'; $definition = (new Definition(Platform::class)) diff --git a/src/platform/src/Bridge/DeepSeek/DeepSeek.php b/src/platform/src/Bridge/DeepSeek/DeepSeek.php new file mode 100644 index 000000000..1659e13e5 --- /dev/null +++ b/src/platform/src/Bridge/DeepSeek/DeepSeek.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\DeepSeek; + +use Symfony\AI\Platform\Model; + +final class DeepSeek extends Model +{ +} diff --git a/src/platform/src/Bridge/DeepSeek/ModelCatalog.php b/src/platform/src/Bridge/DeepSeek/ModelCatalog.php new file mode 100644 index 000000000..fd8c1431a --- /dev/null +++ b/src/platform/src/Bridge/DeepSeek/ModelCatalog.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\DeepSeek; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\ModelCatalog\AbstractModelCatalog; + +/** + * @author Oskar Stark + */ +final class ModelCatalog extends AbstractModelCatalog +{ + /** + * @param array}> $additionalModels + */ + public function __construct(array $additionalModels = []) + { + $defaultModels = [ + 'deepseek-chat' => [ + 'class' => DeepSeek::class, + 'capabilities' => [ + Capability::INPUT_MESSAGES, + Capability::OUTPUT_TEXT, + Capability::OUTPUT_STREAMING, + Capability::TOOL_CALLING, + ], + ], + 'deepseek-reasoner' => [ + 'class' => DeepSeek::class, + 'capabilities' => [ + Capability::INPUT_MESSAGES, + Capability::OUTPUT_TEXT, + Capability::OUTPUT_STREAMING, + ], + ], + ]; + + $this->models = array_merge($defaultModels, $additionalModels); + } +} diff --git a/src/platform/src/Bridge/DeepSeek/ModelClient.php b/src/platform/src/Bridge/DeepSeek/ModelClient.php new file mode 100644 index 000000000..5978ca05e --- /dev/null +++ b/src/platform/src/Bridge/DeepSeek/ModelClient.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\DeepSeek; + +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Oskar Stark + */ +final readonly class ModelClient implements ModelClientInterface +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + } + + public function supports(Model $model): bool + { + return $model instanceof DeepSeek; + } + + public function request(Model $model, array|string $payload, array $options = []): RawHttpResult + { + return new RawHttpResult($this->httpClient->request('POST', 'https://api.deepseek.com/chat/completions', [ + 'auth_bearer' => $this->apiKey, + 'json' => array_merge($options, $payload), + ])); + } +} diff --git a/src/platform/src/Bridge/DeepSeek/PlatformFactory.php b/src/platform/src/Bridge/DeepSeek/PlatformFactory.php new file mode 100644 index 000000000..40f4d2555 --- /dev/null +++ b/src/platform/src/Bridge/DeepSeek/PlatformFactory.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\DeepSeek; + +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +final readonly class PlatformFactory +{ + public static function create( + #[\SensitiveParameter] string $apiKey, + ?HttpClientInterface $httpClient = null, + ModelCatalogInterface $modelCatalog = new ModelCatalog(), + ?Contract $contract = null, + ): Platform { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + return new Platform( + [new ModelClient($httpClient, $apiKey)], + [new ResultConverter()], + $modelCatalog, + $contract ?? Contract::create(), + ); + } +} diff --git a/src/platform/src/Bridge/DeepSeek/ResultConverter.php b/src/platform/src/Bridge/DeepSeek/ResultConverter.php new file mode 100644 index 000000000..b7499344b --- /dev/null +++ b/src/platform/src/Bridge/DeepSeek/ResultConverter.php @@ -0,0 +1,186 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\DeepSeek; + +use Symfony\AI\Platform\Exception\ContentFilterException; +use Symfony\AI\Platform\Exception\InvalidRequestException; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\ChoiceResult; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\Result\ResultInterface; +use Symfony\AI\Platform\Result\StreamResult; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\AI\Platform\Result\ToolCall; +use Symfony\AI\Platform\Result\ToolCallResult; +use Symfony\AI\Platform\ResultConverterInterface; +use Symfony\Component\HttpClient\Chunk\ServerSentEvent; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Oskar Stark + */ +final class ResultConverter implements ResultConverterInterface +{ + public function supports(Model $model): bool + { + return $model instanceof DeepSeek; + } + + public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface + { + if ($options['stream'] ?? false) { + return new StreamResult($this->convertStream($result->getObject())); + } + + $data = $result->getData(); + + if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) { + throw new ContentFilterException($data['error']['message']); + } + + if (isset($data['error']['code'])) { + match ($data['error']['code']) { + 'content_filter' => throw new ContentFilterException($data['error']['message']), + 'invalid_request_error' => throw new InvalidRequestException($data['error']['message']), + default => throw new RuntimeException($data['error']['message']), + }; + } + + $choices = array_map($this->convertChoice(...), $data['choices']); + + return 1 === \count($choices) ? $choices[0] : new ChoiceResult(...$choices); + } + + private function convertStream(HttpResponse $result): \Generator + { + $toolCalls = []; + foreach ((new EventSourceHttpClient())->stream($result) as $chunk) { + if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) { + continue; + } + + $data = $chunk->getArrayData(); + + if ($this->streamIsToolCall($data)) { + $toolCalls = $this->convertStreamToToolCalls($toolCalls, $data); + } + + if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) { + yield new ToolCallResult(...array_map($this->convertToolCall(...), $toolCalls)); + } + + if (!isset($data['choices'][0]['delta']['content'])) { + continue; + } + + yield $data['choices'][0]['delta']['content']; + } + } + + /** + * @param array $toolCalls + * @param array $data + * + * @return array + */ + private function convertStreamToToolCalls(array $toolCalls, array $data): array + { + if (!isset($data['choices'][0]['delta']['tool_calls'])) { + return $toolCalls; + } + + foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) { + if (isset($toolCall['id'])) { + // initialize tool call + $toolCalls[$i] = [ + 'id' => $toolCall['id'], + 'function' => $toolCall['function'], + ]; + continue; + } + + // add arguments delta to tool call + $toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments']; + } + + return $toolCalls; + } + + /** + * @param array $data + */ + private function streamIsToolCall(array $data): bool + { + return isset($data['choices'][0]['delta']['tool_calls']); + } + + /** + * @param array $data + */ + private function isToolCallsStreamFinished(array $data): bool + { + return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason']; + } + + /** + * @param array{ + * index: int, + * message: array{ + * role: 'assistant', + * content: ?string, + * tool_calls: array{ + * id: string, + * type: 'function', + * function: array{ + * name: string, + * arguments: string + * }, + * }, + * refusal: ?mixed + * }, + * logprobs: string, + * finish_reason: 'stop'|'length'|'tool_calls'|'content_filter', + * } $choice + */ + private function convertChoice(array $choice): ToolCallResult|TextResult + { + if ('tool_calls' === $choice['finish_reason']) { + return new ToolCallResult(...array_map([$this, 'convertToolCall'], $choice['message']['tool_calls'])); + } + + if (\in_array($choice['finish_reason'], ['stop', 'length'], true)) { + return new TextResult($choice['message']['content']); + } + + throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason'])); + } + + /** + * @param array{ + * id: string, + * type: 'function', + * function: array{ + * name: string, + * arguments: string + * } + * } $toolCall + */ + private function convertToolCall(array $toolCall): ToolCall + { + $arguments = json_decode($toolCall['function']['arguments'], true, \JSON_THROW_ON_ERROR); + + return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments); + } +} diff --git a/src/platform/src/Bridge/DeepSeek/TokenOutputProcessor.php b/src/platform/src/Bridge/DeepSeek/TokenOutputProcessor.php new file mode 100644 index 000000000..fdef48362 --- /dev/null +++ b/src/platform/src/Bridge/DeepSeek/TokenOutputProcessor.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\DeepSeek; + +use Symfony\AI\Agent\Output; +use Symfony\AI\Agent\OutputProcessorInterface; +use Symfony\AI\Platform\Metadata\TokenUsage; +use Symfony\AI\Platform\Result\StreamResult; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Oskar Stark + */ +final class TokenOutputProcessor implements OutputProcessorInterface +{ + public function processOutput(Output $output): void + { + if ($output->getResult() instanceof StreamResult) { + // Streams have to be handled manually as the tokens are part of the streamed chunks + return; + } + + $rawResponse = $output->getResult()->getRawResult()?->getObject(); + if (!$rawResponse instanceof ResponseInterface) { + return; + } + + $metadata = $output->getResult()->getMetadata(); + + $tokenUsage = new TokenUsage(); + + $content = $rawResponse->toArray(false); + + if (!\array_key_exists('usage', $content)) { + $metadata->add('token_usage', $tokenUsage); + + return; + } + + $usage = $content['usage']; + + $tokenUsage->promptTokens = $usage['prompt_tokens'] ?? null; + $tokenUsage->completionTokens = $usage['completion_tokens'] ?? null; + $tokenUsage->cachedTokens = $usage['prompt_cache_hit_tokens'] ?? null; + $tokenUsage->totalTokens = $usage['total_tokens'] ?? null; + + $metadata->add('token_usage', $tokenUsage); + } +} diff --git a/src/platform/src/Exception/InvalidRequestException.php b/src/platform/src/Exception/InvalidRequestException.php new file mode 100644 index 000000000..d17c2512b --- /dev/null +++ b/src/platform/src/Exception/InvalidRequestException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Exception; + +class InvalidRequestException extends InvalidArgumentException +{ +} diff --git a/src/platform/tests/Bridge/DeepSeek/DeepSeekTest.php b/src/platform/tests/Bridge/DeepSeek/DeepSeekTest.php new file mode 100644 index 000000000..988dd2f77 --- /dev/null +++ b/src/platform/tests/Bridge/DeepSeek/DeepSeekTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\DeepSeek; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\DeepSeek\DeepSeek; + +/** + * @author Oskar Stark + */ +final class DeepSeekTest extends TestCase +{ + public function testItCreatesDeepSeekWithDefaultSettings() + { + $deepSeek = new DeepSeek('deepseek-chat'); + + $this->assertSame('deepseek-chat', $deepSeek->getName()); + $this->assertSame([], $deepSeek->getOptions()); + } + + public function testItCreatesDeepSeekWithCustomSettings() + { + $deepSeek = new DeepSeek('deepseek-chat', [], ['temperature' => 0.5]); + + $this->assertSame('deepseek-chat', $deepSeek->getName()); + $this->assertSame(['temperature' => 0.5], $deepSeek->getOptions()); + } + + public function testItCreatesDeepSeekReasoner() + { + $deepSeek = new DeepSeek('deepseek-reasoner'); + + $this->assertSame('deepseek-reasoner', $deepSeek->getName()); + $this->assertSame([], $deepSeek->getOptions()); + } +} diff --git a/src/platform/tests/Bridge/DeepSeek/ModelCatalogTest.php b/src/platform/tests/Bridge/DeepSeek/ModelCatalogTest.php new file mode 100644 index 000000000..40c3a07b1 --- /dev/null +++ b/src/platform/tests/Bridge/DeepSeek/ModelCatalogTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\DeepSeek; + +use Symfony\AI\Platform\Bridge\DeepSeek\DeepSeek; +use Symfony\AI\Platform\Bridge\DeepSeek\ModelCatalog; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; +use Symfony\AI\Platform\Tests\ModelCatalogTestCase; + +/** + * @author Oskar Stark + */ +final class ModelCatalogTest extends ModelCatalogTestCase +{ + public static function modelsProvider(): iterable + { + yield 'deepseek-chat' => ['deepseek-chat', DeepSeek::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING, Capability::TOOL_CALLING]]; + yield 'deepseek-reasoner' => ['deepseek-reasoner', DeepSeek::class, [Capability::INPUT_MESSAGES, Capability::OUTPUT_TEXT, Capability::OUTPUT_STREAMING]]; + } + + protected function createModelCatalog(): ModelCatalogInterface + { + return new ModelCatalog(); + } +} diff --git a/src/platform/tests/Bridge/DeepSeek/ModelClientTest.php b/src/platform/tests/Bridge/DeepSeek/ModelClientTest.php new file mode 100644 index 000000000..904ea0e87 --- /dev/null +++ b/src/platform/tests/Bridge/DeepSeek/ModelClientTest.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\DeepSeek; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\DeepSeek\DeepSeek; +use Symfony\AI\Platform\Bridge\DeepSeek\ModelClient; +use Symfony\AI\Platform\Model; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +/** + * @author Oskar Stark + */ +final class ModelClientTest extends TestCase +{ + public function testSupportsDeepSeekModel() + { + $httpClient = new MockHttpClient(); + $modelClient = new ModelClient($httpClient, 'test-api-key'); + + $model = new DeepSeek('deepseek-chat'); + $this->assertTrue($modelClient->supports($model)); + } + + public function testDoesNotSupportOtherModels() + { + $httpClient = new MockHttpClient(); + $modelClient = new ModelClient($httpClient, 'test-api-key'); + + $model = new Model('gpt-4'); + $this->assertFalse($modelClient->supports($model)); + } + + public function testRequestSendsToCorrectEndpoint() + { + $requestMade = false; + $httpClient = new MockHttpClient(function ($method, $url, $options) use (&$requestMade) { + $requestMade = true; + self::assertSame('POST', $method); + self::assertSame('https://api.deepseek.com/chat/completions', $url); + self::assertArrayHasKey('normalized_headers', $options); + self::assertArrayHasKey('authorization', $options['normalized_headers']); + self::assertSame('Authorization: Bearer test-api-key', $options['normalized_headers']['authorization'][0]); + + return new JsonMockResponse(['choices' => [['message' => ['content' => 'Hello'], 'finish_reason' => 'stop']]]); + }); + + $modelClient = new ModelClient($httpClient, 'test-api-key'); + $model = new DeepSeek('deepseek-chat'); + + $modelClient->request($model, ['messages' => [['role' => 'user', 'content' => 'Hi']]]); + $this->assertTrue($requestMade); + } + + public function testRequestMergesOptionsWithPayload() + { + $requestMade = false; + $httpClient = new MockHttpClient(function ($method, $url, $options) use (&$requestMade) { + $requestMade = true; + $body = json_decode($options['body'], true); + self::assertArrayHasKey('messages', $body); + self::assertArrayHasKey('temperature', $body); + self::assertSame(0.7, $body['temperature']); + + return new JsonMockResponse(['choices' => [['message' => ['content' => 'Hello'], 'finish_reason' => 'stop']]]); + }); + + $modelClient = new ModelClient($httpClient, 'test-api-key'); + $model = new DeepSeek('deepseek-chat'); + + $modelClient->request( + $model, + ['messages' => [['role' => 'user', 'content' => 'Hi']]], + ['temperature' => 0.7] + ); + $this->assertTrue($requestMade); + } +} diff --git a/src/platform/tests/Bridge/DeepSeek/ResultConverterTest.php b/src/platform/tests/Bridge/DeepSeek/ResultConverterTest.php new file mode 100644 index 000000000..01b7c4d65 --- /dev/null +++ b/src/platform/tests/Bridge/DeepSeek/ResultConverterTest.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Tests\Bridge\DeepSeek; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\DeepSeek\DeepSeek; +use Symfony\AI\Platform\Bridge\DeepSeek\ResultConverter; +use Symfony\AI\Platform\Exception\ContentFilterException; +use Symfony\AI\Platform\Exception\InvalidRequestException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\AI\Platform\Result\ToolCallResult; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\JsonMockResponse; + +/** + * @author Oskar Stark + */ +final class ResultConverterTest extends TestCase +{ + public function testSupportsDeepSeekModel() + { + $converter = new ResultConverter(); + $model = new DeepSeek('deepseek-chat'); + + $this->assertTrue($converter->supports($model)); + } + + public function testDoesNotSupportOtherModels() + { + $converter = new ResultConverter(); + $model = new Model('gpt-4'); + + $this->assertFalse($converter->supports($model)); + } + + public function testConvertTextResponse() + { + $httpClient = new MockHttpClient(new JsonMockResponse([ + 'choices' => [ + [ + 'index' => 0, + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello, how can I help you?', + ], + 'finish_reason' => 'stop', + ], + ], + ])); + + $httpResponse = $httpClient->request('POST', 'https://api.deepseek.com/chat/completions'); + $converter = new ResultConverter(); + + $result = $converter->convert(new RawHttpResult($httpResponse)); + + $this->assertInstanceOf(TextResult::class, $result); + $this->assertSame('Hello, how can I help you?', $result->getContent()); + } + + public function testConvertToolCallResponse() + { + $httpClient = new MockHttpClient(new JsonMockResponse([ + 'choices' => [ + [ + 'index' => 0, + 'message' => [ + 'role' => 'assistant', + 'content' => null, + 'tool_calls' => [ + [ + 'id' => 'call_abc123', + 'type' => 'function', + 'function' => [ + 'name' => 'get_weather', + 'arguments' => '{"location":"Paris"}', + ], + ], + ], + ], + 'finish_reason' => 'tool_calls', + ], + ], + ])); + + $httpResponse = $httpClient->request('POST', 'https://api.deepseek.com/chat/completions'); + $converter = new ResultConverter(); + + $result = $converter->convert(new RawHttpResult($httpResponse)); + + $this->assertInstanceOf(ToolCallResult::class, $result); + $this->assertCount(1, $result->getContent()); + $this->assertSame('call_abc123', $result->getContent()[0]->getId()); + $this->assertSame('get_weather', $result->getContent()[0]->getName()); + $this->assertSame(['location' => 'Paris'], $result->getContent()[0]->getArguments()); + } + + public function testConvertThrowsContentFilterException() + { + $this->expectException(ContentFilterException::class); + $this->expectExceptionMessage('Content filtered'); + + $httpClient = new MockHttpClient(new JsonMockResponse([ + 'error' => [ + 'code' => 'content_filter', + 'message' => 'Content filtered', + ], + ])); + + $httpResponse = $httpClient->request('POST', 'https://api.deepseek.com/chat/completions'); + $converter = new ResultConverter(); + + $converter->convert(new RawHttpResult($httpResponse)); + } + + public function testConvertThrowsInvalidRequestException() + { + $this->expectException(InvalidRequestException::class); + $this->expectExceptionMessage('Invalid request'); + + $httpClient = new MockHttpClient(new JsonMockResponse([ + 'error' => [ + 'code' => 'invalid_request_error', + 'message' => 'Invalid request', + ], + ])); + + $httpResponse = $httpClient->request('POST', 'https://api.deepseek.com/chat/completions'); + $converter = new ResultConverter(); + + $converter->convert(new RawHttpResult($httpResponse)); + } +}