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
51 changes: 48 additions & 3 deletions src/GrokProvider.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 @@ -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
{
Expand All @@ -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,
) {
}

Expand Down Expand Up @@ -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<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);
}

/**
* 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<Effort>
*/
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.
*/
Expand Down
88 changes: 88 additions & 0 deletions tests/Unit/GrokEffortTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?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\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');
});
});
Loading