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'); + }); +});