From 2ad27c1e4330ed9d797ab3f30c7ed61d1226cce9 Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Fri, 31 Jul 2026 16:26:06 +0100 Subject: [PATCH] Map reasoning effort onto DeepSeek's nested thinking object Thinking is enabled by default here, so "none" has to disable it explicitly rather than omitting the field. Pro treats low as high, so low is not offered there rather than being sent and quietly upgraded. --- src/DeepSeekProvider.php | 60 ++++++++++++++++++- tests/Unit/DeepSeekEffortTest.php | 97 +++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/DeepSeekEffortTest.php diff --git a/src/DeepSeekProvider.php b/src/DeepSeekProvider.php index f00c3f0..333b8c3 100644 --- a/src/DeepSeekProvider.php +++ b/src/DeepSeekProvider.php @@ -17,9 +17,11 @@ use Generator; use PapiAI\Core\Contracts\NamedToolSelectableInterface; use PapiAI\Core\Contracts\ProviderInterface; +use PapiAI\Core\Effort; use PapiAI\Core\Exception\AuthenticationException; use PapiAI\Core\Exception\ProviderException; use PapiAI\Core\Exception\RateLimitException; +use PapiAI\Core\Exception\UnknownEffortException; use PapiAI\Core\Message; use PapiAI\Core\Response; use PapiAI\Core\Role; @@ -45,9 +47,8 @@ * @see https://api-docs.deepseek.com/ * * @psalm-import-type ChatOptions from ProviderInterface * - * The neutral `effort` option is accepted and ignored here. DeepSeek does expose reasoning control, as a nested thinking object rather than a flat level, but papi does not map it yet, so the option is accepted and ignored for now. Ignoring it - * degrades nothing the caller was promised, which is why it is silent where an unhonourable - * `toolChoice` throws. + * The neutral effort option maps to DeepSeek's nested thinking object. Thinking is on by + * default here, so "none" disables it explicitly rather than omitting the field. */ class DeepSeekProvider implements ProviderInterface, NamedToolSelectableInterface { @@ -72,6 +73,7 @@ public function __construct( private readonly string $apiKey, private readonly string $defaultModel = self::MODEL_DEEPSEEK_V4_FLASH, private readonly int $defaultMaxTokens = 4096, + private readonly ?Effort $defaultEffort = null, ) { } @@ -228,9 +230,61 @@ private function buildPayload(array $messages, array $options): array } } + // Reasoning effort. DeepSeek nests it, and thinking is on by default, so "none" has to + // disable it explicitly rather than simply omitting the field. + $effort = $this->effortFor($options); + + if ($effort !== null) { + $payload['thinking'] = $this->thinkingFor($effort, (string) ($options['model'] ?? $this->defaultModel)); + } + return $payload; } + /** + * The effort this request asks for: the per-call option, else the provider default. + * + * @param array $options The caller's request options + * + * @throws UnknownEffortException When the level is not one core defines + */ + private function effortFor(array $options): ?Effort + { + if (!isset($options['effort'])) { + return $this->defaultEffort; + } + + $level = (string) $options['effort']; + + return Effort::tryFrom($level) ?? throw new UnknownEffortException($level); + } + + /** + * Build DeepSeek's nested thinking object. + * + * Its scale is low, high and max, with no medium. Pro currently treats low as high, so it is + * not offered there rather than being sent and quietly upgraded. + * + * @return array{type: string, reasoning_effort?: string} + */ + private function thinkingFor(Effort $effort, string $model): array + { + if (!$effort->thinks()) { + return ['type' => 'disabled']; + } + + $offered = str_contains($model, 'pro') + ? [Effort::High, Effort::Maximum] + : [Effort::Low, Effort::High, Effort::Maximum]; + + $narrowed = $effort->nearestOf($offered); + + return [ + 'type' => 'enabled', + 'reasoning_effort' => $narrowed === Effort::Maximum ? 'max' : $narrowed->value, + ]; + } + /** * Convert a Message to OpenAI-compatible API format. */ diff --git a/tests/Unit/DeepSeekEffortTest.php b/tests/Unit/DeepSeekEffortTest.php new file mode 100644 index 0000000..447c10d --- /dev/null +++ b/tests/Unit/DeepSeekEffortTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use PapiAI\Core\Effort; +use PapiAI\Core\Message; +use PapiAI\DeepSeek\DeepSeekProvider; + +/** + * Captures the request payload so effort mapping can be asserted without HTTP. + */ +class TestableDeepSeekEffortProvider extends DeepSeekProvider +{ + public array $lastPayload = []; + + protected function request(array $payload): array + { + $this->lastPayload = $payload; + + return ['choices' => [['message' => ['role' => 'assistant', 'content' => 'ok'], 'finish_reason' => 'stop']]]; + } +} + +describe('DeepSeekProvider reasoning effort', function () { + beforeEach(function () { + $this->provider = new TestableDeepSeekEffortProvider('test-api-key'); + $this->chat = fn (array $options) => $this->provider->chat([Message::user('hi')], $options); + $this->thinking = fn () => $this->provider->lastPayload['thinking'] ?? []; + }); + + it('nests the level inside a thinking object, which is DeepSeek\'s shape', function () { + ($this->chat)(['effort' => 'high']); + + expect(($this->thinking)())->toBe(['type' => 'enabled', 'reasoning_effort' => 'high']); + }); + + it('disables thinking outright for none', function () { + // Thinking is on by default here, so "none" has to say so explicitly. + ($this->chat)(['effort' => 'none']); + + expect(($this->thinking)())->toBe(['type' => 'disabled']); + }); + + it('uses DeepSeek\'s own three levels', function () { + $levels = []; + + foreach (['low', 'high', 'maximum'] as $level) { + ($this->chat)(['effort' => $level]); + $levels[] = ($this->thinking)()['reasoning_effort']; + } + + expect($levels)->toBe(['low', 'high', 'max']); + }); + + it('narrows medium, which DeepSeek does not have', function () { + ($this->chat)(['effort' => 'medium']); + + expect(($this->thinking)()['reasoning_effort'])->toBe('high'); + }); + + it('keeps Pro off the low level it does not honour', function () { + ($this->chat)(['effort' => 'low', 'model' => 'deepseek-v4-pro']); + + expect(($this->thinking)()['reasoning_effort'])->toBe('high'); + }); + + it('sends nothing when the caller does not ask', function () { + ($this->chat)([]); + + expect($this->provider->lastPayload)->not->toHaveKey('thinking'); + }); + + it('rejects a level it does not recognise', function () { + expect(fn () => ($this->chat)(['effort' => 'enormous'])) + ->toThrow(InvalidArgumentException::class, 'enormous'); + }); + + it('accepts a provider-level default the call can override', function () { + $provider = new TestableDeepSeekEffortProvider('k', 'deepseek-v4-flash', 4096, Effort::Maximum); + + $provider->chat([Message::user('hi')], []); + expect($provider->lastPayload['thinking']['reasoning_effort'])->toBe('max'); + + $provider->chat([Message::user('hi')], ['effort' => 'low']); + expect($provider->lastPayload['thinking']['reasoning_effort'])->toBe('low'); + }); +});