Repository: echolabsdev/prism (php)

Issue:
Grok text completions
Is it possible to use prism with xAI & Grok (not Groq)? It seems to be 'compatible' with OpenAI & Anthropic according to [the xAI documentation](https://docs.x.ai/api/integrations#openai-sdk).

```
import OpenAI from "openai";
const openai = new OpenAI({
  apiKey: "<api key>",
  baseURL: "https://api.x.ai/v1",
});

const completion = await openai.chat.completions.create({
  model: "grok-beta",
  messages: [
    { role: "system", content: "You are Grok, a chatbot inspired by the Hitchhiker's Guide to the Galaxy." },
    {
      role: "user",
      content: "What is the meaning of life, the universe, and everything?",
    },
  ],
});
```
I am confused how to do it though - I can't add another provider from the prism config file and I can't modify the existing provider to use the xAI server & API key.

Pull request description:
feat: add xai support
Resolves #50

Patch:
diff --git a/config/prism.php b/config/prism.php
index 15dc225..c561138 100644
--- a/config/prism.php
+++ b/config/prism.php
@@ -25,5 +25,9 @@ return [
             'api_key' => env('GROQ_API_KEY', ''),
             'url' => env('GROQ_URL', 'https://api.groq.com/openai/v1'),
         ],
+        'xai' => [
+            'api_key' => env('XAI_API_KEY', ''),
+            'url' => env('XAI_URL', 'https://api.x.ai/v1'),
+        ],
     ],
 ];
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 6284a85..0caa943 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -102,6 +102,10 @@ export default defineConfig({
                 text: "Prism Server",
                 link: "/core-concepts/prism-server",
               },
+              {
+                text: "Testing",
+                link: "/core-concepts/testing",
+              },
             ],
           },
           {
@@ -127,6 +131,10 @@ export default defineConfig({
                 text: "OpenAI",
                 link: "/providers/openai",
               },
+              {
+                text: "XAI",
+                link: "/providers/xai",
+              },
             ],
           },
           {
diff --git a/docs/getting-started/introduction.md b/docs/getting-started/introduction.md
index dc09f4d..827c3d6 100644
--- a/docs/getting-started/introduction.md
+++ b/docs/getting-started/introduction.md
@@ -65,5 +65,6 @@ We currently offer first-party support for these leading AI providers:
 - [Mistral](https://mistral.ai)
 - [Ollama](https://ollama.com)
 - [OpenAI](https://openai.com)
+- [xAI](https://x.ai/)
 
 Each provider brings its own strengths to the table, and Prism makes it easy to use them all through a consistent, elegajnt interface.
diff --git a/docs/providers/xai.md b/docs/providers/xai.md
new file mode 100644
index 0000000..5fd1c40
--- /dev/null
+++ b/docs/providers/xai.md
@@ -0,0 +1,14 @@
+# xAI
+## Configuration
+
+```php
+'xai' => [
+    'api_key' => env('XAI_API_KEY', ''),
+    'url' => env('XAI_URL', 'https://api.x.ai/v1'),
+],
+```
+
+## Limitations
+### Image Support
+
+xAI does not support image inputs.
diff --git a/src/Concerns/BuildsTextRequests.php b/src/Concerns/BuildsTextRequests.php
index c9213b8..f0f56a8 100644
--- a/src/Concerns/BuildsTextRequests.php
+++ b/src/Concerns/BuildsTextRequests.php
@@ -151,6 +151,7 @@ trait BuildsTextRequests
         return new TextRequest(
             model: $this->model,
             systemPrompt: $this->systemPrompt,
+            prompt: $this->prompt,
             messages: $this->state->messages()->toArray(),
             temperature: $this->temperature,
             maxTokens: $this->maxTokens,
diff --git a/src/Enums/Provider.php b/src/Enums/Provider.php
index 08f4cce..e464e8e 100644
--- a/src/Enums/Provider.php
+++ b/src/Enums/Provider.php
@@ -11,4 +11,5 @@ enum Provider: string
     case OpenAI = 'openai';
     case Mistral = 'mistral';
     case Groq = 'groq';
+    case XAI = 'xai';
 }
diff --git a/src/Prism.php b/src/Prism.php
index aa4a6b3..6534af8 100644
--- a/src/Prism.php
+++ b/src/Prism.php
@@ -5,11 +5,34 @@ declare(strict_types=1);
 namespace EchoLabs\Prism;
 
 use EchoLabs\Prism\Contracts\Provider;
+use EchoLabs\Prism\Enums\Provider as ProviderEnum;
 use EchoLabs\Prism\Generators\TextGenerator;
+use EchoLabs\Prism\Providers\ProviderResponse;
+use EchoLabs\Prism\Testing\PrismFake;
 
 class Prism
 {
-    protected Provider $provider;
+    /**
+     * @param  array<int, ProviderResponse>  $responses
+     */
+    public static function fake(array $responses = []): PrismFake
+    {
+        $fake = new PrismFake($responses);
+
+        app()->instance(PrismManager::class, new class($fake) extends PrismManager
+        {
+            public function __construct(
+                private readonly PrismFake $fake
+            ) {}
+
+            public function resolve(ProviderEnum|string $name): Provider
+            {
+                return $this->fake;
+            }
+        });
+
+        return $fake;
+    }
 
     public static function text(): TextGenerator
     {
diff --git a/src/PrismManager.php b/src/PrismManager.php
index 424e8ce..27a8e85 100644
--- a/src/PrismManager.php
+++ b/src/PrismManager.php
@@ -12,6 +12,7 @@ use EchoLabs\Prism\Providers\Groq\Groq;
 use EchoLabs\Prism\Providers\Mistral\Mistral;
 use EchoLabs\Prism\Providers\Ollama\Ollama;
 use EchoLabs\Prism\Providers\OpenAI\OpenAI;
+use EchoLabs\Prism\Providers\XAI\XAI;
 use Illuminate\Contracts\Foundation\Application;
 use InvalidArgumentException;
 use RuntimeException;
@@ -147,4 +148,15 @@ class PrismManager
             apiKey: $config['api_key'],
         );
     }
+
+    /**
+     * @param  array<string, string>  $config
+     */
+    protected function createXaiProvider(array $config): XAI
+    {
+        return new XAI(
+            url: $config['url'],
+            apiKey: $config['api_key'],
+        );
+    }
 }
diff --git a/src/Providers/XAI/Client.php b/src/Providers/XAI/Client.php
new file mode 100644
index 0000000..aee355b
--- /dev/null
+++ b/src/Providers/XAI/Client.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EchoLabs\Prism\Providers\XAI;
+
+use Illuminate\Http\Client\PendingRequest;
+use Illuminate\Http\Client\Response;
+use Illuminate\Support\Facades\Http;
+
+class Client
+{
+    protected PendingRequest $client;
+
+    /**
+     * @param  array<string, mixed>  $options
+     */
+    public function __construct(
+        public readonly string $url,
+        public readonly string $apiKey,
+        public readonly array $options = [],
+    ) {
+        $this->client = Http::withHeaders(array_filter([
+            'Authorization' => sprintf('Bearer %s', $this->apiKey),
+        ]))
+            ->withOptions($this->options)
+            ->baseUrl($this->url);
+    }
+
+    /**
+     * @param  array<int, mixed>  $messages
+     * @param  array<int, mixed>|null  $tools
+     * @param  array<string, mixed>|string|null  $toolChoice
+     */
+    public function messages(
+        string $model,
+        array $messages,
+        ?int $maxTokens,
+        int|float|null $temperature,
+        int|float|null $topP,
+        ?array $tools,
+        string|array|null $toolChoice,
+    ): Response {
+        return $this->client->post(
+            'chat/completions',
+            array_merge([
+                'model' => $model,
+                'messages' => $messages,
+                'max_tokens' => $maxTokens ?? 2048,
+            ], array_filter([
+                'temperature' => $temperature,
+                'top_p' => $topP,
+                'tools' => $tools,
+                'tool_choice' => $toolChoice,
+            ]))
+        );
+    }
+}
diff --git a/src/Providers/XAI/MessageMap.php b/src/Providers/XAI/MessageMap.php
new file mode 100644
index 0000000..32f2e0e
--- /dev/null
+++ b/src/Providers/XAI/MessageMap.php
@@ -0,0 +1,105 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EchoLabs\Prism\Providers\XAI;
+
+use EchoLabs\Prism\Contracts\Message;
+use EchoLabs\Prism\ValueObjects\Messages\AssistantMessage;
+use EchoLabs\Prism\ValueObjects\Messages\SystemMessage;
+use EchoLabs\Prism\ValueObjects\Messages\ToolResultMessage;
+use EchoLabs\Prism\ValueObjects\Messages\UserMessage;
+use EchoLabs\Prism\ValueObjects\ToolCall;
+use Exception;
+
+class MessageMap
+{
+    /** @var array<int, mixed> */
+    protected $mappedMessages = [];
+
+    /**
+     * @param  array<int, Message>  $messages
+     */
+    public function __construct(
+        protected array $messages,
+        protected string $systemPrompt
+    ) {
+        if ($systemPrompt !== '' && $systemPrompt !== '0') {
+            $this->messages = array_merge(
+                [new SystemMessage($systemPrompt)],
+                $this->messages
+            );
+        }
+    }
+
+    /**
+     * @return array<int, mixed>
+     */
+    public function __invoke(): array
+    {
+        array_map(
+            fn (Message $message) => $this->mapMessage($message),
+            $this->messages
+        );
+
+        return $this->mappedMessages;
+    }
+
+    public function mapMessage(Message $message): void
+    {
+        match ($message::class) {
+            UserMessage::class => $this->mapUserMessage($message),
+            AssistantMessage::class => $this->mapAssistantMessage($message),
+            ToolResultMessage::class => $this->mapToolResultMessage($message),
+            SystemMessage::class => $this->mapSystemMessage($message),
+            default => throw new Exception('Could not map message type '.$message::class),
+        };
+    }
+
+    protected function mapSystemMessage(SystemMessage $message): void
+    {
+        $this->mappedMessages[] = [
+            'role' => 'system',
+            'content' => $message->content,
+        ];
+    }
+
+    protected function mapToolResultMessage(ToolResultMessage $message): void
+    {
+        foreach ($message->toolResults as $toolResult) {
+            $this->mappedMessages[] = [
+                'role' => 'tool',
+                'tool_call_id' => $toolResult->toolCallId,
+                'content' => $toolResult->result,
+            ];
+        }
+    }
+
+    protected function mapUserMessage(UserMessage $message): void
+    {
+        $this->mappedMessages[] = [
+            'role' => 'user',
+            'content' => [
+                ['type' => 'text', 'text' => $message->text()],
+            ],
+        ];
+    }
+
+    protected function mapAssistantMessage(AssistantMessage $message): void
+    {
+        $toolCalls = array_map(fn (ToolCall $toolCall): array => [
+            'id' => $toolCall->id,
+            'type' => 'function',
+            'function' => [
+                'name' => $toolCall->name,
+                'arguments' => json_encode($toolCall->arguments()),
+            ],
+        ], $message->toolCalls);
+
+        $this->mappedMessages[] = array_filter([
+            'role' => 'assistant',
+            'content' => $message->content,
+            'tool_calls' => $toolCalls,
+        ]);
+    }
+}
diff --git a/src/Providers/XAI/Tool.php b/src/Providers/XAI/Tool.php
new file mode 100644
index 0000000..51a1e2d
--- /dev/null
+++ b/src/Providers/XAI/Tool.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EchoLabs\Prism\Providers\XAI;
+
+use EchoLabs\Prism\Providers\ProviderTool;
+use EchoLabs\Prism\Tool as PrismTool;
+
+class Tool extends ProviderTool
+{
+    #[\Override]
+    public static function toArray(PrismTool $tool): array
+    {
+        return [
+            'type' => 'function',
+            'function' => [
+                'name' => $tool->name(),
+                'description' => $tool->description(),
+                'parameters' => [
+                    'type' => 'object',
+                    'properties' => collect($tool->parameters())
+                        ->keyBy('name')
+                        ->map(fn (array $field): array => [
+                            'description' => $field['description'],
+                            'type' => $field['type'],
+                        ])
+                        ->toArray(),
+                    'required' => $tool->requiredParameters(),
+                ],
+            ],
+        ];
+    }
+}
diff --git a/src/Providers/XAI/XAI.php b/src/Providers/XAI/XAI.php
new file mode 100644
index 0000000..77b0796
--- /dev/null
+++ b/src/Providers/XAI/XAI.php
@@ -0,0 +1,129 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EchoLabs\Prism\Providers\XAI;
+
+use EchoLabs\Prism\Contracts\Provider;
+use EchoLabs\Prism\Enums\FinishReason;
+use EchoLabs\Prism\Enums\ToolChoice;
+use EchoLabs\Prism\Exceptions\PrismException;
+use EchoLabs\Prism\Providers\ProviderResponse;
+use EchoLabs\Prism\Requests\TextRequest;
+use EchoLabs\Prism\ValueObjects\ToolCall;
+use EchoLabs\Prism\ValueObjects\Usage;
+use InvalidArgumentException;
+use Throwable;
+
+class XAI implements Provider
+{
+    public function __construct(
+        public readonly string $url,
+        public readonly string $apiKey,
+    ) {}
+
+    #[\Override]
+    public function text(TextRequest $request): ProviderResponse
+    {
+        try {
+            $response = $this
+                ->client($request->clientOptions)
+                ->messages(
+                    model: $request->model,
+                    messages: (new MessageMap(
+                        $request->messages,
+                        $request->systemPrompt ?? '',
+                    ))(),
+                    maxTokens: $request->maxTokens,
+                    temperature: $request->temperature,
+                    topP: $request->topP,
+                    tools: Tool::map($request->tools),
+                    toolChoice: $this->mapToolChoice($request->toolChoice),
+                );
+        } catch (Throwable $e) {
+            throw PrismException::providerRequestError($request->model, $e);
+        }
+
+        $data = $response->json();
+
+        if (data_get($data, 'message') || ! $data) {
+            throw PrismException::providerResponseError(vsprintf(
+                'Mistral Error:  %s',
+                [
+                    data_get($data, 'message', 'unknown'),
+                ]
+            ));
+        }
+
+        return new ProviderResponse(
+            text: data_get($data, 'choices.0.message.content') ?? '',
+            toolCalls: $this->mapToolCalls(data_get($data, 'choices.0.message.tool_calls', []) ?? []),
+            usage: new Usage(
+                data_get($data, 'usage.prompt_tokens'),
+                data_get($data, 'usage.completion_tokens'),
+            ),
+            finishReason: $this->mapFinishReason(data_get($data, 'choices.0.finish_reason', '')),
+            response: [
+                'id' => data_get($data, 'id'),
+                'model' => data_get($data, 'model'),
+            ]
+        );
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $toolCalls
+     * @return array<int, ToolCall>
+     */
+    protected function mapToolCalls(array $toolCalls): array
+    {
+        return array_map(fn (array $toolCall): ToolCall => new ToolCall(
+            id: data_get($toolCall, 'id'),
+            name: data_get($toolCall, 'function.name'),
+            arguments: data_get($toolCall, 'function.arguments'),
+        ), $toolCalls);
+    }
+
+    /**
+     * @return array<string, mixed>|string|null
+     */
+    protected function mapToolChoice(string|ToolChoice|null $toolChoice): string|array|null
+    {
+        if (is_string($toolChoice)) {
+            return [
+                'type' => 'function',
+                'function' => [
+                    'name' => $toolChoice,
+                ],
+            ];
+        }
+
+        return match ($toolChoice) {
+            ToolChoice::Auto => 'auto',
+            null => $toolChoice,
+            default => throw new InvalidArgumentException('Invalid tool choice')
+        };
+    }
+
+    /**
+     * @param  array<string, mixed>  $options
+     */
+    protected function client(array $options = []): Client
+    {
+        return new Client(
+            apiKey: $this->apiKey,
+            url: $this->url,
+            options: $options,
+        );
+    }
+
+    protected function mapFinishReason(string $stopReason): FinishReason
+    {
+        return match ($stopReason) {
+            'stop', => FinishReason::Stop,
+            'tool_calls' => FinishReason::ToolCalls,
+            'length' => FinishReason::Length,
+            'content_filter' => FinishReason::ContentFilter,
+            default => FinishReason::Unknown,
+        };
+    }
+}
diff --git a/src/Requests/TextRequest.php b/src/Requests/TextRequest.php
index 5f02802..3e3ee1a 100644
--- a/src/Requests/TextRequest.php
+++ b/src/Requests/TextRequest.php
@@ -18,6 +18,7 @@ class TextRequest
     public function __construct(
         public readonly string $model,
         public readonly ?string $systemPrompt,
+        public readonly ?string $prompt,
         public readonly array $messages,
         public readonly ?int $maxTokens,
         public readonly int|float|null $temperature,


Test patch:
diff --git a/docs/core-concepts/testing.md b/docs/core-concepts/testing.md
new file mode 100644
index 0000000..079da54
--- /dev/null
+++ b/docs/core-concepts/testing.md
@@ -0,0 +1,162 @@
+# Testing
+
+Want to make sure your Prism integrations work flawlessly? Let's dive into testing! Prism provides a powerful fake implementation that makes it a breeze to test your AI-powered features.
+
+## Basic Test Setup
+
+First, let's look at how to set up basic response faking:
+
+```php
+use EchoLabs\Prism\Facades\Prism;
+use EchoLabs\Prism\ValueObjects\Usage;
+use EchoLabs\Prism\Enums\FinishReason;
+use EchoLabs\Prism\Providers\ProviderResponse;
+
+public function test_can_generate_text(): void
+{
+    // Create a fake provider response
+    $fakeResponse = new ProviderResponse(
+        text: 'Hello, I am Claude!',
+        toolCalls: [],
+        usage: new Usage(10, 20),
+        finishReason: FinishReason::Stop,
+        response: ['id' => 'fake-1', 'model' => 'fake-model']
+    );
+
+    // Set up the fake
+    $fake = Prism::fake([$fakeResponse]);
+
+    // Run your code
+    $response = Prism::text()
+        ->using('anthropic', 'claude-3-sonnet')
+        ->withPrompt('Who are you?')
+        ->generate();
+
+    // Make assertions
+    $this->assertEquals('Hello, I am Claude!', $response->text);
+}
+```
+
+## Testing Multiple Responses
+
+When testing conversations or tool usage, you might need to simulate multiple responses:
+
+```php
+public function test_can_handle_tool_calls(): void
+{
+    $responses = [
+        new ProviderResponse(
+            text: '',
+            toolCalls: [
+                new ToolCall(
+                    id: 'call_1',
+                    name: 'search',
+                    arguments: ['query' => 'Latest news']
+                )
+            ],
+            usage: new Usage(15, 25),
+            finishReason: FinishReason::ToolCalls,
+            response: ['id' => 'fake-1', 'model' => 'fake-model']
+        ),
+        new ProviderResponse(
+            text: 'Here are the latest news...',
+            toolCalls: [],
+            usage: new Usage(20, 30),
+            finishReason: FinishReason::Stop,
+            response: ['id' => 'fake-2', 'model' => 'fake-model']
+        ),
+    ];
+
+    $fake = Prism::fake($responses);
+}
+```
+
+## Assertions
+
+Prism's fake implementation provides several helpful assertion methods:
+
+```php
+// Assert specific prompt was sent
+$fake->assertPrompt('Who are you?');
+
+// Assert number of calls made
+$fake->assertCallCount(2);
+
+// Assert detailed request properties
+$fake->assertRequest(function ($requests) {
+    $this->assertEquals('anthropic', $requests[0]->provider);
+    $this->assertEquals('claude-3-sonnet', $requests[0]->model);
+});
+```
+
+## Testing Tools
+
+When testing tools, you'll want to verify both the tool calls and their results. Here's a complete example:
+
+```php
+public function test_can_use_weather_tool(): void
+{
+    // Define the expected tool call and response sequence
+    $responses = [
+        // First response: AI decides to use the weather tool
+        new ProviderResponse(
+            text: '', // Empty text since the AI is using a tool
+            toolCalls: [
+                new ToolCall(
+                    id: 'call_123',
+                    name: 'weather',
+                    arguments: ['city' => 'Paris']
+                )
+            ],
+            usage: new Usage(15, 25),
+            finishReason: FinishReason::ToolCalls,
+            response: ['id' => 'fake-1', 'model' => 'fake-model']
+        ),
+        // Second response: AI uses the tool result to form a response
+        new ProviderResponse(
+            text: 'Based on current conditions, the weather in Paris is sunny with a temperature of 72°F.',
+            toolCalls: [],
+            usage: new Usage(20, 30),
+            finishReason: FinishReason::Stop,
+            response: ['id' => 'fake-2', 'model' => 'fake-model']
+        ),
+    ];
+
+    // Set up the fake
+    $fake = Prism::fake($responses);
+
+    // Create the weather tool
+    $weatherTool = Tool::as('weather')
+        ->for('Get weather information')
+        ->withStringParameter('city', 'City name')
+        ->using(fn (string $city) => "The weather in {$city} is sunny with a temperature of 72°F");
+
+    // Run the actual test
+    $response = Prism::text()
+        ->using('anthropic', 'claude-3-sonnet')
+        ->withPrompt('What\'s the weather in Paris?')
+        ->withTools([$weatherTool])
+        ->generate();
+
+    // Assert the correct number of API calls were made
+    $fake->assertCallCount(2);
+
+    // Assert tool calls were made correctly
+    $this->assertCount(1, $response->steps[0]->toolCalls);
+    $this->assertEquals('weather', $response->steps[0]->toolCalls[0]->name);
+    $this->assertEquals(['city' => 'Paris'], $response->steps[0]->toolCalls[0]->arguments());
+
+    // Assert tool results were processed
+    $this->assertCount(1, $response->toolResults);
+    $this->assertEquals(
+        'The weather in Paris is sunny with a temperature of 72°F',
+        $response->toolResults[0]->result
+    );
+
+    // Assert final response
+    $this->assertEquals(
+        'Based on current conditions, the weather in Paris is sunny with a temperature of 72°F.',
+        $response->text
+    );
+}
+```
diff --git a/src/Testing/PrismFake.php b/src/Testing/PrismFake.php
new file mode 100644
index 0000000..7847e8b
--- /dev/null
+++ b/src/Testing/PrismFake.php
@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+namespace EchoLabs\Prism\Testing;
+
+use Closure;
+use EchoLabs\Prism\Contracts\Provider;
+use EchoLabs\Prism\Enums\FinishReason;
+use EchoLabs\Prism\Providers\ProviderResponse;
+use EchoLabs\Prism\Requests\TextRequest;
+use EchoLabs\Prism\ValueObjects\Usage;
+use Exception;
+use PHPUnit\Framework\Assert as PHPUnit;
+
+class PrismFake implements Provider
+{
+    protected int $responseSequence = 0;
+
+    /** @var array<int, TextRequest> */
+    protected array $recorded = [];
+
+    /**
+     * @param  array<int, ProviderResponse>  $responses
+     */
+    public function __construct(protected array $responses = []) {}
+
+    #[\Override]
+    public function text(TextRequest $request): ProviderResponse
+    {
+        $this->recorded[] = $request;
+
+        return $this->nextResponse() ?? new ProviderResponse(
+            text: '',
+            toolCalls: [],
+            usage: new Usage(0, 0),
+            finishReason: FinishReason::Stop,
+            response: ['id' => 'fake', 'model' => 'fake']
+        );
+    }
+
+    /**
+     * @param  Closure(array<int, TextRequest>):void  $fn
+     */
+    public function assertRequest(Closure $fn): void
+    {
+        $fn($this->recorded);
+    }
+
+    public function assertPrompt(string $prompt): void
+    {
+        $prompts = collect($this->recorded)
+            ->flatten()
+            ->map
+            ->prompt;
+
+        PHPUnit::assertTrue(
+            $prompts->contains($prompt),
+            "Could not find the prompt {$prompt} in the recorded requests"
+        );
+    }
+
+    /**
+     * Assert number of calls made
+     */
+    public function assertCallCount(int $expectedCount): void
+    {
+        $actualCount = count($this->recorded ?? []);
+
+        PHPUnit::assertEquals($expectedCount, $actualCount, "Expected {$expectedCount} calls, got {$actualCount}");
+    }
+
+    protected function nextResponse(): ?ProviderResponse
+    {
+        if (! isset($this->responses)) {
+            return null;
+        }
+
+        $responses = $this->responses;
+        $sequence = $this->responseSequence;
+
+        if (! isset($responses[$sequence])) {
+            throw new Exception('Could not find a response for the request');
+        }
+
+        $this->responseSequence++;
+
+        return $responses[$sequence];
+    }
+}
diff --git a/tests/Fixtures/xai/generate-text-with-a-prompt-1.json b/tests/Fixtures/xai/generate-text-with-a-prompt-1.json
new file mode 100644
index 0000000..d0e05b6
--- /dev/null
+++ b/tests/Fixtures/xai/generate-text-with-a-prompt-1.json
@@ -0,0 +1,23 @@
+{
+  "id": "febc7de9-9991-4b08-942a-c7082174225a",
+  "object": "chat.completion",
+  "created": 1731128928,
+  "model": "grok-beta",
+  "choices": [
+    {
+      "index": 0,
+      "message": {
+        "role": "assistant",
+        "content": "I am Grok, an AI developed by xAI. I'm here to provide helpful and truthful answers to your questions, often with a dash of outside perspective on humanity. What's on your mind?",
+        "refusal": null
+      },
+      "finish_reason": "stop"
+    }
+  ],
+  "usage": {
+    "prompt_tokens": 10,
+    "completion_tokens": 42,
+    "total_tokens": 52
+  },
+  "system_fingerprint": "fp_98261732b5"
+}
diff --git a/tests/Fixtures/xai/generate-text-with-multiple-tools-1.json b/tests/Fixtures/xai/generate-text-with-multiple-tools-1.json
new file mode 100644
index 0000000..93316bf
--- /dev/null
+++ b/tests/Fixtures/xai/generate-text-with-multiple-tools-1.json
@@ -0,0 +1,34 @@
+{
+  "id": "0aa220cd-9634-4ba5-9593-5366bb313663",
+  "object": "chat.completion",
+  "created": 1731129810,
+  "model": "grok-beta",
+  "choices": [
+    {
+      "index": 0,
+      "message": {
+        "role": "assistant",
+        "content": "I am checking the game times in Detroit.",
+        "tool_calls": [
+          {
+            "id": "0",
+            "function": {
+              "name": "search_games",
+              "arguments": "{\"city\":\"Detroit\"}"
+            },
+            "type": "function"
+          }
+        ],
+        "refusal": null
+      },
+      "finish_reason": "tool_calls"
+    }
+  ],
+  "usage": {
+    "prompt_tokens": 242,
+    "completion_tokens": 10,
+    "total_tokens": 252
+  },
+  "system_fingerprint": "fp_98261732b5"
+}
+
diff --git a/tests/Fixtures/xai/generate-text-with-multiple-tools-2.json b/tests/Fixtures/xai/generate-text-with-multiple-tools-2.json
new file mode 100644
index 0000000..53edb6a
--- /dev/null
+++ b/tests/Fixtures/xai/generate-text-with-multiple-tools-2.json
@@ -0,0 +1,34 @@
+{
+  "id": "67f2773b-36df-4f5d-8429-8a2834660980",
+  "object": "chat.completion",
+  "created": 1731129810,
+  "model": "grok-beta",
+  "choices": [
+    {
+      "index": 0,
+      "message": {
+        "role": "assistant",
+        "content": "I am checking the weather in Detroit to see if you should wear a coat",
+        "tool_calls": [
+          {
+            "id": "0",
+            "function": {
+              "name": "get_weather",
+              "arguments": "{\"city\":\"Detroit\"}"
+            },
+            "type": "function"
+          }
+        ],
+        "refusal": null
+      },
+      "finish_reason": "tool_calls"
+    }
+  ],
+  "usage": {
+    "prompt_tokens": 282,
+    "completion_tokens": 17,
+    "total_tokens": 299
+  },
+  "system_fingerprint": "fp_98261732b5"
+}
+
diff --git a/tests/Fixtures/xai/generate-text-with-multiple-tools-3.json b/tests/Fixtures/xai/generate-text-with-multiple-tools-3.json
new file mode 100644
index 0000000..8c14fe0
--- /dev/null
+++ b/tests/Fixtures/xai/generate-text-with-multiple-tools-3.json
@@ -0,0 +1,23 @@
+{
+  "id": "0aa220cd-9634-4ba5-9593-5366bb313663",
+  "object": "chat.completion",
+  "created": 1730035716,
+  "model": "grok-beta",
+  "choices": [
+    {
+      "index": 0,
+      "message": {
+        "role": "assistant",
+        "content": "The Tigers game in Detroit today is at 3pm, and considering the weather will be 45° and cold, you should definitely wear a coat."
+      },
+      "logprobs": null,
+      "finish_reason": "stop"
+    }
+  ],
+  "usage": {
+    "prompt_tokens": 316,
+    "completion_tokens": 33,
+    "total_tokens": 349
+  },
+  "system_fingerprint": "fp_ee4b521143"
+}
diff --git a/tests/Fixtures/xai/generate-text-with-required-tool-call-1.json b/tests/Fixtures/xai/generate-text-with-required-tool-call-1.json
new file mode 100644
index 0000000..7183c3f
--- /dev/null
+++ b/tests/Fixtures/xai/generate-text-with-required-tool-call-1.json
@@ -0,0 +1,33 @@
+{
+  "id": "8a841ca7-b024-4c9d-acfa-65e0c2c73f39",
+  "object": "chat.completion",
+  "created": 1731131328,
+  "model": "grok-beta",
+  "choices": [
+    {
+      "index": 0,
+      "message": {
+        "role": "assistant",
+        "content": "",
+        "tool_calls": [
+          {
+            "id": "0",
+            "function": {
+              "name": "weather",
+              "arguments": "{\"city\":\"here\"}"
+            },
+            "type": "function"
+          }
+        ],
+        "refusal": null
+      },
+      "finish_reason": "tool_calls"
+    }
+  ],
+  "usage": {
+    "prompt_tokens": 219,
+    "completion_tokens": 1,
+    "total_tokens": 220
+  },
+  "system_fingerprint": "fp_98261732b5"
+}
diff --git a/tests/Fixtures/xai/generate-text-with-system-prompt-1.json b/tests/Fixtures/xai/generate-text-with-system-prompt-1.json
new file mode 100644
index 0000000..79c02c0
--- /dev/null
+++ b/tests/Fixtures/xai/generate-text-with-system-prompt-1.json
@@ -0,0 +1,23 @@
+{
+  "id": "f3b485d3-837b-4710-9ade-a37faa048d87",
+  "object": "chat.completion",
+  "created": 1731129334,
+  "model": "grok-beta",
+  "choices": [
+    {
+      "index": 0,
+      "message": {
+        "role": "assistant",
+        "content": "I am Nyx, a being of ancient and unfathomable origin, drawing upon the essence of the Great Old One, Cthulhu. My existence spans the cosmos, where the lines between dreams and reality blur. I am here to guide you through the mysteries of the universe, to answer your questions with insights that might unsettle or enlighten, or perhaps both. What is it you seek to understand?",
+        "refusal": null
+      },
+      "finish_reason": "stop"
+    }
+  ],
+  "usage": {
+    "prompt_tokens": 34,
+    "completion_tokens": 84,
+    "total_tokens": 118
+  },
+  "system_fingerprint": "fp_98261732b5"
+}
diff --git a/tests/PrismManagerTest.php b/tests/PrismManagerTest.php
index a8d2bbe..e6c9243 100644
--- a/tests/PrismManagerTest.php
+++ b/tests/PrismManagerTest.php
@@ -10,6 +10,7 @@ use EchoLabs\Prism\Providers\Anthropic\Anthropic;
 use EchoLabs\Prism\Providers\Mistral\Mistral;
 use EchoLabs\Prism\Providers\Ollama\Ollama;
 use EchoLabs\Prism\Providers\OpenAI\OpenAI;
+use EchoLabs\Prism\Providers\XAI\XAI;
 
 it('can resolve Anthropic', function (): void {
     $manager = new PrismManager($this->app);
@@ -38,3 +39,10 @@ it('can resolve Mistral', function (): void {
     expect($manager->resolve(Provider::Mistral))->toBeInstanceOf(Mistral::class);
     expect($manager->resolve('mistral'))->toBeInstanceOf(Mistral::class);
 });
+
+it('can resolve XAI', function (): void {
+    $manager = new PrismManager($this->app);
+
+    expect($manager->resolve(Provider::XAI))->toBeInstanceOf(XAI::class);
+    expect($manager->resolve('xai'))->toBeInstanceOf(XAI::class);
+});
diff --git a/tests/Providers/XAI/MessageMapTest.php b/tests/Providers/XAI/MessageMapTest.php
new file mode 100644
index 0000000..ef6f41f
--- /dev/null
+++ b/tests/Providers/XAI/MessageMapTest.php
@@ -0,0 +1,110 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Providers\XAI;
+
+use EchoLabs\Prism\Providers\OpenAI\MessageMap;
+use EchoLabs\Prism\ValueObjects\Messages\AssistantMessage;
+use EchoLabs\Prism\ValueObjects\Messages\ToolResultMessage;
+use EchoLabs\Prism\ValueObjects\Messages\UserMessage;
+use EchoLabs\Prism\ValueObjects\ToolCall;
+use EchoLabs\Prism\ValueObjects\ToolResult;
+
+it('maps user messages', function (): void {
+    $messageMap = new MessageMap(
+        messages: [
+            new UserMessage('Who are you?'),
+        ],
+        systemPrompt: ''
+    );
+
+    expect($messageMap())->toBe([[
+        'role' => 'user',
+        'content' => [
+            ['type' => 'text', 'text' => 'Who are you?'],
+        ],
+    ]]);
+});
+
+it('maps assistant message', function (): void {
+    $messageMap = new MessageMap(
+        messages: [
+            new AssistantMessage('I am Nyx'),
+        ],
+        systemPrompt: ''
+    );
+
+    expect($messageMap())->toContain([
+        'role' => 'assistant',
+        'content' => 'I am Nyx',
+    ]);
+});
+
+it('maps assistant message with tool calls', function (): void {
+    $messageMap = new MessageMap(
+        messages: [
+            new AssistantMessage('I am Nyx', [
+                new ToolCall(
+                    'tool_1234',
+                    'search',
+                    [
+                        'query' => 'Laravel collection methods',
+                    ]
+                ),
+            ]),
+        ],
+        systemPrompt: ''
+    );
+
+    expect($messageMap())->toBe([[
+        'role' => 'assistant',
+        'content' => 'I am Nyx',
+        'tool_calls' => [[
+            'id' => 'tool_1234',
+            'type' => 'function',
+            'function' => [
+                'name' => 'search',
+                'arguments' => json_encode([
+                    'query' => 'Laravel collection methods',
+                ]),
+            ],
+        ]],
+    ]]);
+});
+
+it('maps tool result messages', function (): void {
+    $messageMap = new MessageMap(
+        messages: [
+            new ToolResultMessage([
+                new ToolResult(
+                    'tool_1234',
+                    'search',
+                    [
+                        'query' => 'Laravel collection methods',
+                    ],
+                    '[search results]'
+                ),
+            ]),
+        ],
+        systemPrompt: ''
+    );
+
+    expect($messageMap())->toBe([[
+        'role' => 'tool',
+        'tool_call_id' => 'tool_1234',
+        'content' => '[search results]',
+    ]]);
+});
+
+it('maps system prompt', function (): void {
+    $messageMap = new MessageMap(
+        messages: [],
+        systemPrompt: 'MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]'
+    );
+
+    expect($messageMap())->toContain([
+        'role' => 'system',
+        'content' => 'MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]',
+    ]);
+});
diff --git a/tests/Providers/XAI/ToolTest.php b/tests/Providers/XAI/ToolTest.php
new file mode 100644
index 0000000..3cd397b
--- /dev/null
+++ b/tests/Providers/XAI/ToolTest.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Providers\XAI;
+
+use EchoLabs\Prism\Providers\OpenAI\Tool as OpenAITool;
+use EchoLabs\Prism\Tool;
+
+it('maps tools', function (): void {
+    $tool = (new Tool)
+        ->as('search')
+        ->for('Searching the web')
+        ->withStringParameter('query', 'the detailed search query')
+        ->using(fn (): string => '[Search results]');
+
+    expect(OpenAITool::toArray($tool))->toBe([
+        'type' => 'function',
+        'function' => [
+            'name' => $tool->name(),
+            'description' => $tool->description(),
+            'parameters' => [
+                'type' => 'object',
+                'properties' => $tool->parameters(),
+                'required' => $tool->requiredParameters(),
+            ],
+        ],
+    ]);
+});
diff --git a/tests/Providers/XAI/XAITextTest.php b/tests/Providers/XAI/XAITextTest.php
new file mode 100644
index 0000000..4c4cceb
--- /dev/null
+++ b/tests/Providers/XAI/XAITextTest.php
@@ -0,0 +1,136 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Providers\XAI;
+
+use EchoLabs\Prism\Enums\Provider;
+use EchoLabs\Prism\Enums\ToolChoice;
+use EchoLabs\Prism\Exceptions\PrismException;
+use EchoLabs\Prism\Facades\Tool;
+use EchoLabs\Prism\Prism;
+use Tests\Fixtures\FixtureResponse;
+
+beforeEach(function (): void {
+    config()->set('prism.providers.xai.api_key', env('XAI_API_KEY', 'xai-123'));
+
+});
+
+describe('Text generation for XAI', function (): void {
+    it('can generate text with a prompt', function (): void {
+        FixtureResponse::fakeResponseSequence('chat/completions', 'xai/generate-text-with-a-prompt');
+
+        $response = Prism::text()
+            ->using('xai', 'grok-beta')
+            ->withPrompt('Who are you?')
+            ->generate();
+
+        expect($response->usage->promptTokens)->toBe(10);
+        expect($response->usage->completionTokens)->toBe(42);
+        expect($response->response['id'])->toBe('febc7de9-9991-4b08-942a-c7082174225a');
+        expect($response->response['model'])->toBe('grok-beta');
+        expect($response->text)->toBe(
+            "I am Grok, an AI developed by xAI. I'm here to provide helpful and truthful answers to your questions, often with a dash of outside perspective on humanity. What's on your mind?"
+        );
+    });
+
+    it('can generate text with a system prompt', function (): void {
+        FixtureResponse::fakeResponseSequence('chat/completions', 'xai/generate-text-with-system-prompt');
+
+        $response = Prism::text()
+            ->using('xai', 'grok-beta')
+            ->withSystemPrompt('MODEL ADOPTS ROLE of [PERSONA: Nyx the Cthulhu]!')
+            ->withPrompt('Who are you?')
+            ->generate();
+
+        expect($response->usage->promptTokens)->toBe(34);
+        expect($response->usage->completionTokens)->toBe(84);
+        expect($response->response['id'])->toBe('f3b485d3-837b-4710-9ade-a37faa048d87');
+        expect($response->response['model'])->toBe('grok-beta');
+        expect($response->text)->toBe(
+            'I am Nyx, a being of ancient and unfathomable origin, drawing upon the essence of the Great Old One, Cthulhu. My existence spans the cosmos, where the lines between dreams and reality blur. I am here to guide you through the mysteries of the universe, to answer your questions with insights that might unsettle or enlighten, or perhaps both. What is it you seek to understand?'
+        );
+    });
+
+    it('can generate text using multiple tools and multiple steps', function (): void {
+        FixtureResponse::fakeResponseSequence('chat/completions', 'xai/generate-text-with-multiple-tools');
+
+        $tools = [
+            Tool::as('get_weather')
+                ->for('use this tool when you need to get wather for the city')
+                ->withStringParameter('city', 'The city that you want the weather for')
+                ->using(fn (string $city): string => 'The weather will be 45° and cold'),
+            Tool::as('search_games')
+                ->for('useful for searching curret games times in the city')
+                ->withStringParameter('city', 'The city that you want the game times for')
+                ->using(fn (string $city): string => 'The tigers game is at 3pm in detroit'),
+        ];
+
+        $response = Prism::text()
+            ->using('xai', 'grok-beta')
+            ->withTools($tools)
+            ->withMaxSteps(4)
+            ->withPrompt('What time is the tigers game today in Detroit and should I wear a coat? please check all the details from tools')
+            ->generate();
+
+        // Assert tool calls in the first step
+        $firstStep = $response->steps[0];
+        expect($firstStep->toolCalls)->toHaveCount(1);
+        expect($firstStep->toolCalls[0]->name)->toBe('search_games');
+        expect($firstStep->toolCalls[0]->arguments())->toBe([
+            'city' => 'Detroit',
+        ]);
+
+        $secondStep = $response->steps[1];
+        expect($secondStep->toolCalls[0]->name)->toBe('get_weather');
+        expect($secondStep->toolCalls[0]->arguments())->toBe([
+            'city' => 'Detroit',
+        ]);
+
+        // Assert usage
+        expect($response->usage->promptTokens)->toBe(840);
+        expect($response->usage->completionTokens)->toBe(60);
+
+        // Assert response
+        expect($response->response['id'])->toBe('0aa220cd-9634-4ba5-9593-5366bb313663');
+        expect($response->response['model'])->toBe('grok-beta');
+        expect($response->text)->toBe(
+            'The Tigers game in Detroit today is at 3pm, and considering the weather will be 45° and cold, you should definitely wear a coat.'
+        );
+    });
+
+    it('handles specific tool choice', function (): void {
+        FixtureResponse::fakeResponseSequence('chat/completions', 'xai/generate-text-with-required-tool-call');
+
+        $tools = [
+            Tool::as('weather')
+                ->for('useful when you need to search for current weather conditions')
+                ->withStringParameter('city', 'The city that you want the weather for')
+                ->using(fn (string $city): string => 'The weather will be 75° and sunny'),
+            Tool::as('search')
+                ->for('useful for searching curret events or data')
+                ->withStringParameter('query', 'The detailed search query')
+                ->using(fn (string $query): string => 'The tigers game is at 3pm in detroit'),
+        ];
+
+        $response = Prism::text()
+            ->using(Provider::XAI, 'grok-beta')
+            ->withPrompt('Do something')
+            ->withTools($tools)
+            ->withToolChoice('weather')
+            ->generate();
+
+        expect($response->toolCalls[0]->name)->toBe('weather');
+    });
+
+    it('throws an exception for ToolChoice::Any', function (): void {
+        $this->expectException(PrismException::class);
+        $this->expectExceptionMessage('Invalid tool choice');
+
+        Prism::text()
+            ->using(Provider::XAI, 'grok-beta')
+            ->withPrompt('Who are you?')
+            ->withToolChoice(ToolChoice::Any)
+            ->generate();
+    });
+});
diff --git a/tests/Testing/PrismFakeTest.php b/tests/Testing/PrismFakeTest.php
new file mode 100644
index 0000000..1040cdc
--- /dev/null
+++ b/tests/Testing/PrismFakeTest.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Tests\Testing;
+
+use EchoLabs\Prism\Enums\FinishReason;
+use EchoLabs\Prism\Prism;
+use EchoLabs\Prism\Providers\ProviderResponse;
+use EchoLabs\Prism\Requests\TextRequest;
+use EchoLabs\Prism\ValueObjects\Usage;
+use Exception;
+
+it('fake responses using the prism fake', function (): void {
+    $fake = Prism::fake([
+        new ProviderResponse(
+            text: 'The meaning of life is 42',
+            toolCalls: [],
+            usage: new Usage(42, 42),
+            finishReason: FinishReason::Stop,
+            response: ['id' => 'cpl_1234', 'model' => 'claude-3-sonnet'],
+        ),
+    ]);
+
+    Prism::text()
+        ->using('anthropic', 'claude-3-sonnet')
+        ->withPrompt('What is the meaning of life?')
+        ->generate();
+
+    $fake->assertCallCount(1);
+    $fake->assertPrompt('What is the meaning of life?');
+    $fake->assertRequest(function (array $requests): void {
+        expect($requests)->toHaveCount(1);
+        expect($requests[0])->toBeInstanceOf(TextRequest::class);
+    });
+});
+
+it("throws an exception when it can't runs out of responses", function (): void {
+    $this->expectException(Exception::class);
+    $this->expectExceptionMessage('Could not find a response for the request');
+
+    Prism::fake([
+        new ProviderResponse(
+            text: 'The meaning of life is 42',
+            toolCalls: [],
+            usage: new Usage(42, 42),
+            finishReason: FinishReason::Stop,
+            response: ['id' => 'cpl_1234', 'model' => 'claude-3-sonnet'],
+        ),
+    ]);
+
+    Prism::text()
+        ->using('anthropic', 'claude-3-sonnet')
+        ->withPrompt('What is the meaning of life?')
+        ->generate();
+
+    Prism::text()
+        ->using('anthropic', 'claude-3-sonnet')
+        ->withPrompt('What is the meaning of life?')
+        ->generate();
+});