AI Integration (Gemini)

Rhapsody ships with a provider-agnostic contract for text and chat generation, plus a Google Gemini implementation of it. It's not tied to any specific feature — it's a general-purpose building block for whatever you want to build with it: content generation, drafting assistance, summarization, chat, or anything else.

v1 scope: text and chat generation only, via Google AI Studio (a single API key — not the GCP-project/service-account flow Vertex AI uses). Streaming, multimodal input, function calling, and embeddings aren't implemented yet — see Swapping or extending the provider below if you need one of those today.

Setup

1. Get an API key

Generate a free key at Google AI Studio, then add it to .env:

GEMINI_API_KEY=your-key-here
GEMINI_MODEL=gemini-2.5-flash

2. Configure

Add an ai block to config.php if it does not already exist (should be near the bottom of the returned array), alongside your other integration settings:

'ai' => [
    'provider' => $_ENV['AI_PROVIDER'] ?? 'gemini',
    'gemini' => [
        'api_key'         => $_ENV['GEMINI_API_KEY'] ?? '',
        'model'           => $_ENV['GEMINI_MODEL'] ?? 'gemini-2.5-flash',
        'timeout'         => (int) ($_ENV['GEMINI_TIMEOUT'] ?? 60),
        'connect_timeout' => (int) ($_ENV['GEMINI_CONNECT_TIMEOUT'] ?? 5),
    ],
],

The default 60-second timeout is deliberately generous — generation can genuinely take tens of seconds for longer output. This isn't a typo; treat a slow response as normal, not as something to chase down.


Basic usage

Type-hint AiClientInterface in any container-resolved class — a controller, a listener, a console command — and the container will inject the bound GeminiClient:

use Rhapsody\Core\Contracts\AiClientInterface;

class MyController extends BaseController
{
    public function __construct(protected AiClientInterface $ai) {}

    public function summarize(Request $request): Response
    {
        $response = $this->ai->generateContent(
            'Summarize this in one sentence: ' . $request->input('text')
        );

        return $this->json(['summary' => $response->getText()]);
    }
}

Multi-turn chat

For a conversation rather than a single prompt, use generateChatContent() with an ordered list of turns:

$response = $this->ai->generateChatContent([
    ['role' => 'user', 'content' => 'What is the capital of France?'],
    ['role' => 'assistant', 'content' => 'Paris.'],
    ['role' => 'user', 'content' => 'What is its population?'],
]);

Options

Both methods accept an $options array as their second argument:

$response = $this->ai->generateContent($prompt, [
    'model'       => 'gemini-2.5-pro',  // override the configured default model
    'temperature' => 0.9,               // 0.0–1.0, higher = more creative/random
    'max_tokens'  => 500,               // maxOutputTokens
    'top_p'       => 0.95,
    'top_k'       => 40,
    'timeout'     => 90,                // override the configured request timeout, in seconds
]);

The response object

generateContent()/generateChatContent() return an AiResponse — not a raw decoded array — so your code has a stable contract even if Gemini's response shape changes, and so a second provider can be added later behind the same shape.

$response = $this->ai->generateContent($prompt);

$response->getText();       // string — the generated text
$response->getFinishReason(); // ?string — e.g. 'STOP', 'MAX_TOKENS', 'SAFETY'
$response->wasTruncated();  // bool — true if cut off by the max_tokens limit
$response->wasBlocked();    // bool — true if blocked by safety filters or recitation
$response->getUsage();      // array — ['prompt_tokens' => .., 'completion_tokens' => .., 'total_tokens' => ..]
$response->getRaw();        // array — the full decoded provider payload, as an escape hatch
Important: a truncated or blocked response is not an exception. Both are legitimate, successful outcomes of a real API call — Gemini generated something, it's just incomplete or was filtered. Check wasTruncated()/wasBlocked() and decide what that means for your use case, rather than expecting these to throw.

Error handling

Genuine failures to communicate with the provider are thrown as exceptions, all extending a common base:

  • AiAuthenticationException – the API key is missing, invalid, or not authorized (HTTP 401/403, or caught locally before any request is sent if no key is configured at all).
  • AiRateLimitException – rate limit or quota exceeded (HTTP 429). Exposes $e->retryAfter (an ?int in seconds) when the provider supplies a Retry-After header.
  • AiTimeoutException – the request took too long, whether at the connection stage or waiting for a response.
  • AiServerException – the provider itself failed (HTTP 5xx) — an outage on Google's side, not a problem with your request.
  • AiException – the base class; catch this for "something failed" generically, or any other failure mode not covered above.
use Rhapsody\Core\Ai\Exceptions\{
    AiAuthenticationException,
    AiRateLimitException,
    AiTimeoutException,
    AiServerException,
    AiException,
};

try {
    $response = $this->ai->generateContent($prompt);
} catch (AiRateLimitException $e) {
    // e.g. tell the user how long to wait
    $wait = $e->retryAfter ?? 30;
} catch (AiTimeoutException $e) {
    // e.g. suggest a shorter prompt and retry
} catch (AiServerException $e) {
    // e.g. the provider is down — try again shortly
} catch (AiAuthenticationException $e) {
    // e.g. log it — this is a config problem, not a per-request one
    error_log($e->getMessage());
} catch (AiException $e) {
    // anything else
}

Exposing it over HTTP: BaseAiController

Rhapsody\Core\Controllers\BaseAiController gives you a ready-made JSON endpoint — the same pattern as BaseBillingController/BaseWebhookController — with every exception above already mapped to a sensible HTTP status:

Exception HTTP status Notes
AiAuthenticationException500Logged server-side; the client just sees a generic message — this is a config problem, not something the user caused.
AiRateLimitException429Retry-After header set when available.
AiTimeoutException504—
AiServerException502—
AiException500Generic fallback.

Wire up a route:

use Rhapsody\Core\Controllers\BaseAiController;

Router::post('/ai/generate', [BaseAiController::class, 'generate'])->middleware('auth');
Don't skip ->middleware('auth') without thinking about it. An unauthenticated public AI endpoint means anyone who finds the URL can spend your API quota. Requiring auth is a sensible default, not a hard requirement — just make the call deliberately.

The endpoint expects a JSON body with a prompt field (and your CSRF _token, since this isn't under api/* and so isn't CSRF-exempt), and returns:

{
    "text": "...",
    "truncated": false,
    "blocked": false,
    "usage": { "prompt_tokens": 12, "completion_tokens": 48, "total_tokens": 60 }
}

Extending for app-specific behaviour

Same pattern as BillingController extends BaseBillingController — extend the base controller rather than duplicating its error handling:

namespace App\Controllers;

use Rhapsody\Core\Controllers\BaseAiController;

class AiController extends BaseAiController
{
    public function generate(Request $request): Response
    {
        // e.g. wrap the user's input in your own prompt template,
        // apply per-user rate limiting, log usage, then delegate:
        return parent::generate($request);
    }
}

The React component

A ready-made island, resources/js/components/AiPrompt.jsx, gives you a working prompt-and-response UI with no extra setup — built on the React Islands bridge.

{{ react_component('AiPrompt', { endpoint: '/ai/generate' }) }}

It requires a CSRF meta tag on the page — the same convention BaseController::react() already uses for full-SPA mode. If the page isn't already using react(), add this to your layout:

<meta name="csrf-token" content="{{ csrf_token() }}">

What it handles for you

  • Disables the form while a request is in flight, and shows a "still working" hint after 8 seconds rather than leaving the user staring at an ambiguous spinner.
  • A Cancel button (via AbortController) — lets the user actually stop a slow request instead of just waiting it out. The request is also aborted automatically if the component unmounts mid-request.
  • 429 responses show the server's Retry-After value when present; 504 and 502 get their own distinct messages rather than a generic "something went wrong."
  • truncated/blocked render as their own inline notices, not folded into an error state — matching AiResponse's design: these are successful responses worth telling the user about, not failures.

Props

Prop Default Description
endpoint/ai/generateThe route to POST to.
placeholder"Ask me anything..."Textarea placeholder text.
buttonLabel"Generate"Submit button text.

Swapping or extending the provider

AiClientInterface exists specifically so nothing in your application code is coupled to Gemini directly — the same reasoning behind going through Omnipay's abstraction for payments rather than writing Stripe-specific code. To add another provider (Claude, OpenAI, or a second Gemini configuration), implement the interface and swap the container binding:

use Rhapsody\Core\Contracts\AiClientInterface;
use Rhapsody\Core\Ai\AiResponse;

class MyOtherProviderClient implements AiClientInterface
{
    public function generateContent(string $prompt, array $options = []): AiResponse { /* ... */ }
    public function generateChatContent(array $messages, array $options = []): AiResponse { /* ... */ }
}

Nothing that type-hints AiClientInterface — your controllers, listeners, commands — needs to change.

If you need something v1 doesn't cover — streaming, multimodal input, function calling, embeddings — the intended path is a new method on AiClientInterface (or a sibling interface, e.g. an EmbeddingClientInterface for embeddings, since that's a different enough use case to warrant its own contract rather than being bolted onto this one) once you have a real caller that needs it, rather than speculative surface area added ahead of time.


Testing

GeminiClient is fully testable without a real API key or network access — it depends on Guzzle's ClientInterface, so tests can swap in a MockHandler:

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response as Psr7Response;

$mock = new MockHandler([
    new Psr7Response(200, [], json_encode([
        'candidates' => [[
            'content'      => ['parts' => [['text' => 'Hello!']]],
            'finishReason' => 'STOP',
        ]],
    ])),
]);

$httpClient = new Client(['handler' => HandlerStack::create($mock)]);
$ai = new GeminiClient($httpClient, ['api_key' => 'test-key']);

$response = $ai->generateContent('Hi');
// $response->getText() === 'Hello!'

See tests/Unit/GeminiClientTest.php for the full suite, including every error-path exception above.