Files
InvoiceShelf/app/Support/Ai/OpenRouterDriver.php
Darko Gjorgjijoski c7fab5d52f feat(ai): Phase 1 — provider configuration, installer step, admin + company settings
Foundation for the AI chatbot + text generation feature. Phase 1 is infrastructure only: driver plumbing, configuration storage with encrypted API keys, global vs per-company resolution, admin + company UI pages, and an optional installer wizard step. The chat assistant and text-generation WYSIWYG integration come in later phases.

**Driver plumbing (app/Support/Ai/)** — AiDriver abstract, AiDriverFactory, AiException, AiChatResponse DTO, OpenRouterDriver concrete implementation. OpenRouter is the OpenAI-compatible aggregator that unlocks hundreds of models behind one API key and one request shape — ideal as the default v1 driver. Drivers are extensible the same way exchange rate drivers are: the module Registry's generic registerDriver('ai', ...) machinery plus a typed Registry::registerAiDriver() convenience wrapper (shipped in the upstream invoiceshelf/modules package in a paired commit).

**AiConfigurationService** — mirrors MailConfigurationService shape but with one deliberate deviation: API keys are encrypted at the service layer via Crypt::encryptString before persistence. OpenRouter bearer tokens have much bigger blast radius than SMTP passwords. Same settings / company_settings tables, same global-vs-per-company pattern, same use_custom_ai_config override toggle. Resolution order: global ai_enabled must be YES, then the company either overrides via use_custom_ai_config=YES (and can opt out with ai_enabled=NO inside the override) or inherits the global config.

**Controllers** — Admin/Settings/AiConfigurationController (global CRUD + driver list + test connection), Company/Settings/CompanyAiConfigurationController (per-company override + test), Setup/AiConfigurationController (installer wizard step, skippable with explicit ai_enabled=NO). API key is always masked as '********' in GET responses — the frontend submits the placeholder back on save and the backend preserves the stored value.

**Installer wizard** — new optional step 7 'AI' between Mail and Account. Default OFF with a Skip button. MailView.vue now routes to installation.ai instead of installation.account; installation.ai then routes to installation.account. Step order comment updated in routes.ts.

**Admin + Company settings pages** — AdminAiConfigView (no toggle, always global) and AiConfigView (with use_custom_ai_config BaseSwitchSection that auto-saves OFF). Both share AiConfigurationForm which renders the driver selector, API key input with show/hide, driver-specific config_fields (base_url for OpenRouter), and per-role enable toggles with free-text model inputs backed by a datalist of suggested models from driver metadata.

**Bootstrap endpoint** — adds an ai block to the response: { enabled, chat_enabled, text_generation_enabled }. All three are booleans resolved through AiConfigurationService::resolveForCompany(). Never leaks the API key. Frontend feature flags read from bootstrapData.ai to decide whether to show Phase 2/3 UI.

**Bouncer ability** 'manage ai config' added to SettingsPolicy, gated on isSuperAdmin() (same pattern as manage email config, manage pdf config).

**Tests** (22 new) — Unit: AiDriverFactory resolves built-in + Registry-contributed drivers, rejects unknown, merges availableDrivers. AiConfigurationService: encryption round-trip, resolution order (3 cases: global off, inherit global, override with company key, override with opt-out), makeDriver null/instance cases, listDrivers metadata. Feature: admin save + read with api key masking, preserve-on-placeholder behavior, company toggle ON/OFF semantics, bootstrap ai flags reflect resolution, company opt-out path.

372 tests pass (was 350, +22). Pint clean. npm run build clean. Phase 2 (chat assistant + tool calling) and Phase 3 (WYSIWYG text generation popup) are separate follow-up commits — this one is the foundation only.
2026-04-11 22:00:00 +02:00

206 lines
6.0 KiB
PHP

<?php
namespace App\Support\Ai;
use Illuminate\Support\Facades\Http;
use Throwable;
/**
* OpenRouter driver.
*
* OpenRouter is an OpenAI-compatible aggregator that routes requests to
* hundreds of underlying LLMs (OpenAI, Anthropic, Google, open-source, etc.)
* behind a single API key and a single request shape. That makes it ideal as
* the default v1 driver — one integration unlocks the whole ecosystem.
*
* Endpoint: POST {base_url}/chat/completions (OpenAI format)
* Auth: Bearer token in Authorization header
* Docs: https://openrouter.ai/docs
*/
class OpenRouterDriver extends AiDriver
{
protected const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
protected const TIMEOUT_SECONDS = 120;
public function chatCompletion(
array $messages,
string $model,
array $tools = [],
array $options = [],
): AiChatResponse {
$payload = array_filter([
'model' => $model,
'messages' => $messages,
'tools' => $tools !== [] ? $tools : null,
'tool_choice' => $tools !== [] ? ($options['tool_choice'] ?? 'auto') : null,
'temperature' => $options['temperature'] ?? null,
'max_tokens' => $options['max_tokens'] ?? null,
], fn ($v) => $v !== null);
try {
$response = Http::withToken($this->apiKey)
->timeout(self::TIMEOUT_SECONDS)
->acceptJson()
->asJson()
->post($this->getBaseUrl().'/chat/completions', $payload);
} catch (Throwable $e) {
throw new AiException(
'OpenRouter request failed: '.$e->getMessage(),
'server_error',
0,
$e,
);
}
if ($response->status() === 401) {
throw new AiException('Invalid OpenRouter API key', 'invalid_key');
}
if ($response->status() === 429) {
throw new AiException('OpenRouter rate limit exceeded', 'rate_limited');
}
if (! $response->successful()) {
$errorBody = $response->json('error.message') ?? $response->body();
throw new AiException(
'OpenRouter returned '.$response->status().': '.$errorBody,
'server_error',
);
}
return $this->parseChatResponse($response->json());
}
public function textCompletion(string $prompt, string $model, array $options = []): string
{
$response = $this->chatCompletion(
[['role' => 'user', 'content' => $prompt]],
$model,
[],
$options,
);
return $response->message ?? '';
}
public function validateConnection(): array
{
try {
$response = Http::withToken($this->apiKey)
->timeout(30)
->acceptJson()
->get($this->getBaseUrl().'/models');
} catch (Throwable $e) {
throw new AiException(
'Unable to reach OpenRouter: '.$e->getMessage(),
'server_error',
0,
$e,
);
}
if ($response->status() === 401) {
throw new AiException('Invalid OpenRouter API key', 'invalid_key');
}
if (! $response->successful()) {
throw new AiException(
'OpenRouter validation failed with status '.$response->status(),
'server_error',
);
}
$data = $response->json('data', []);
return [
'ok' => true,
'model_count' => is_array($data) ? count($data) : 0,
];
}
public function listModels(): array
{
try {
$response = Http::withToken($this->apiKey)
->timeout(30)
->acceptJson()
->get($this->getBaseUrl().'/models');
} catch (Throwable) {
return [];
}
if (! $response->successful()) {
return [];
}
$models = $response->json('data', []);
if (! is_array($models)) {
return [];
}
return array_map(
fn (array $m): array => [
'value' => $m['id'] ?? '',
'label' => $m['name'] ?? ($m['id'] ?? ''),
],
$models,
);
}
/**
* @param array<string, mixed>|null $body
*/
protected function parseChatResponse(?array $body): AiChatResponse
{
$choice = $body['choices'][0] ?? [];
$message = $choice['message'] ?? [];
$text = $message['content'] ?? null;
$finishReason = $choice['finish_reason'] ?? 'stop';
// Normalize OpenAI's tool_calls shape — each entry has id, type='function',
// and function.{name,arguments} where arguments is a JSON string we need to decode.
$toolCalls = [];
foreach ($message['tool_calls'] ?? [] as $call) {
$name = $call['function']['name'] ?? null;
$rawArgs = $call['function']['arguments'] ?? '{}';
$args = is_string($rawArgs) ? (json_decode($rawArgs, true) ?: []) : (array) $rawArgs;
if ($name === null) {
continue;
}
$toolCalls[] = [
'id' => $call['id'] ?? '',
'name' => $name,
'arguments' => $args,
];
}
$usage = [];
if (isset($body['usage'])) {
$usage = [
'tokens_in' => (int) ($body['usage']['prompt_tokens'] ?? 0),
'tokens_out' => (int) ($body['usage']['completion_tokens'] ?? 0),
];
}
return new AiChatResponse(
message: $text,
toolCalls: $toolCalls,
finishReason: $finishReason,
usage: $usage,
model: $body['model'] ?? null,
);
}
protected function getBaseUrl(): string
{
$url = $this->config['base_url'] ?? self::DEFAULT_BASE_URL;
return rtrim($url, '/');
}
}