diff --git a/src/GrokProvider.php b/src/GrokProvider.php index 300fc42..fb07c51 100644 --- a/src/GrokProvider.php +++ b/src/GrokProvider.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; @@ -44,9 +46,8 @@ * - grok-2 (multimodal) * * @see https://docs.x.ai/docs * - * The neutral `effort` option is accepted and ignored here. xAI does expose a top-level reasoning_effort parameter, but papi does not map it yet, so the option is accepted and ignored for now. Note Grok 4.5 cannot disable reasoning at all. 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 xAI's top-level reasoning_effort. Note Grok 4.5 reasons + * always and cannot be switched off, so "none" narrows to the shallowest level it accepts. */ class GrokProvider implements ProviderInterface, NamedToolSelectableInterface { @@ -71,6 +72,7 @@ public function __construct( private readonly string $apiKey, private readonly string $defaultModel = self::MODEL_GROK_4_5, private readonly int $defaultMaxTokens = 4096, + private readonly ?Effort $defaultEffort = null, ) { } @@ -214,9 +216,52 @@ private function buildPayload(array $messages, array $options): array } } + // Reasoning effort. xAI takes a flat level, but the accepted set varies: Grok 4.5 cannot + // switch reasoning off at all, so "none" narrows to the shallowest level it does accept. + $effort = $this->effortFor($options); + + if ($effort !== null) { + $model = (string) ($options['model'] ?? $this->defaultModel); + $payload['reasoning_effort'] = $effort->nearestOf($this->levelsFor($model))->value; + } + 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); + } + + /** + * The levels this model accepts. + * + * Grok 4.5 reasons always and has no "none"; 4.3 and earlier can be switched off. + * + * @return non-empty-list + */ + private function levelsFor(string $model): array + { + if (str_contains($model, '4.5')) { + return [Effort::Low, Effort::Medium, Effort::High]; + } + + return [Effort::None, Effort::Low, Effort::Medium, Effort::High]; + } + /** * Convert a Message to OpenAI-compatible API format. */ diff --git a/tests/Unit/GrokEffortTest.php b/tests/Unit/GrokEffortTest.php new file mode 100644 index 0000000..8627c9d --- /dev/null +++ b/tests/Unit/GrokEffortTest.php @@ -0,0 +1,88 @@ + + * + * 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\Grok\GrokProvider; + +/** + * Captures the request payload so effort mapping can be asserted without HTTP. + */ +class TestableGrokEffortProvider extends GrokProvider +{ + public array $lastPayload = []; + + protected function request(array $payload): array + { + $this->lastPayload = $payload; + + return ['choices' => [['message' => ['role' => 'assistant', 'content' => 'ok'], 'finish_reason' => 'stop']]]; + } +} + +describe('GrokProvider reasoning effort', function () { + beforeEach(function () { + $this->provider = new TestableGrokEffortProvider('test-api-key'); + $this->chat = fn (array $options) => $this->provider->chat([Message::user('hi')], $options); + }); + + it('maps the middle levels straight through', function () { + foreach (['low', 'medium', 'high'] as $level) { + ($this->chat)(['effort' => $level, 'model' => 'grok-4.3']); + + expect($this->provider->lastPayload['reasoning_effort'])->toBe($level); + } + }); + + it('lets 4.3 switch reasoning off', function () { + ($this->chat)(['effort' => 'none', 'model' => 'grok-4.3']); + + expect($this->provider->lastPayload['reasoning_effort'])->toBe('none'); + }); + + it('will not claim 4.5 can switch reasoning off, because it cannot', function () { + ($this->chat)(['effort' => 'none', 'model' => 'grok-4.5']); + + expect($this->provider->lastPayload['reasoning_effort'])->toBe('low'); + }); + + it('narrows the levels above what xAI offers', function () { + foreach (['extra-high', 'maximum'] as $level) { + ($this->chat)(['effort' => $level, 'model' => 'grok-4.5']); + + expect($this->provider->lastPayload['reasoning_effort'])->toBe('high'); + } + }); + + it('sends nothing when the caller does not ask', function () { + ($this->chat)([]); + + expect($this->provider->lastPayload)->not->toHaveKey('reasoning_effort'); + }); + + 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 TestableGrokEffortProvider('k', 'grok-4.3', 4096, Effort::High); + + $provider->chat([Message::user('hi')], []); + expect($provider->lastPayload['reasoning_effort'])->toBe('high'); + + $provider->chat([Message::user('hi')], ['effort' => 'low']); + expect($provider->lastPayload['reasoning_effort'])->toBe('low'); + }); +});