mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-17 14:25:21 +00:00
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.
This commit is contained in:
205
app/Support/Ai/OpenRouterDriver.php
Normal file
205
app/Support/Ai/OpenRouterDriver.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?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, '/');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user