Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 57 additions & 3 deletions src/DeepSeekProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
{
Expand All @@ -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,
) {
}

Expand Down Expand Up @@ -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<string, mixed> $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.
*/
Expand Down
97 changes: 97 additions & 0 deletions tests/Unit/DeepSeekEffortTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

/*
* This file is part of PapiAI,
* A simple but powerful PHP library for building AI agents.
*
* (c) Marcello Duarte <marcello.duarte@gmail.com>
*
* 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');
});
});
Loading