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.
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
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?intin seconds) when the provider supplies aRetry-Afterheader.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 |
|---|---|---|
AiAuthenticationException | 500 | Logged server-side; the client just sees a generic message — this is a config problem, not something the user caused. |
AiRateLimitException | 429 | Retry-After header set when available. |
AiTimeoutException | 504 | — |
AiServerException | 502 | — |
AiException | 500 | Generic fallback. |
Wire up a route:
use Rhapsody\Core\Controllers\BaseAiController;
Router::post('/ai/generate', [BaseAiController::class, 'generate'])->middleware('auth');
->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-Aftervalue when present; 504 and 502 get their own distinct messages rather than a generic "something went wrong." truncated/blockedrender as their own inline notices, not folded into an error state — matchingAiResponse's design: these are successful responses worth telling the user about, not failures.
Props
| Prop | Default | Description |
|---|---|---|
endpoint | /ai/generate | The 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.