258 خطوط
12 KiB
PHP
258 خطوط
12 KiB
PHP
<?php
|
|
|
|
namespace App\Modules\AI\Http;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Modules\AI\Infrastructure\AiEndpointPolicy;
|
|
use App\Modules\Identity\Application\RolePermissions;
|
|
use App\Modules\Identity\Domain\Enums\Permission;
|
|
use App\Modules\Tenancy\Application\TenantContext;
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
use InvalidArgumentException;
|
|
|
|
final class AiConnectionController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly TenantContext $tenant,
|
|
private readonly RolePermissions $permissions,
|
|
private readonly AiEndpointPolicy $endpointPolicy,
|
|
) {}
|
|
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$this->author($request);
|
|
$items = DB::table('ai_provider_connections')->where('organization_id', $this->tenant->id())->orderByDesc('is_default')->orderBy('name')->get()->map(fn ($row) => $this->payload($row));
|
|
|
|
return response()->json(['data' => ['items' => $items, 'providers' => $this->providers()]]);
|
|
}
|
|
|
|
public function store(Request $request): JsonResponse
|
|
{
|
|
$this->author($request);
|
|
$data = $this->validated($request);
|
|
$id = (string) Str::ulid();
|
|
DB::transaction(function () use ($data, $id, $request): void {
|
|
$this->lockOrganization();
|
|
DB::table('ai_provider_connections')->insert([
|
|
'id' => $id, 'organization_id' => $this->tenant->id(), 'created_by' => $request->user()->getKey(),
|
|
...$this->columns($data), 'encrypted_api_key' => filled($data['apiKey'] ?? null) ? Crypt::encryptString($data['apiKey']) : null,
|
|
'models' => json_encode([]), 'is_default' => false, 'created_at' => now(), 'updated_at' => now(),
|
|
]);
|
|
$this->normalizeDefault();
|
|
}, 3);
|
|
|
|
return response()->json(['data' => $this->payload($this->connection($id))], 201);
|
|
}
|
|
|
|
public function update(Request $request, string $connection): JsonResponse
|
|
{
|
|
$this->author($request);
|
|
$data = $this->validated($request);
|
|
$columns = [...$this->columns($data), 'updated_at' => now()];
|
|
if (filled($data['apiKey'] ?? null)) {
|
|
$columns['encrypted_api_key'] = Crypt::encryptString($data['apiKey']);
|
|
}
|
|
if (($data['clearApiKey'] ?? false) === true) {
|
|
$columns['encrypted_api_key'] = null;
|
|
}
|
|
DB::transaction(function () use ($connection, $columns): void {
|
|
$this->lockOrganization();
|
|
$row = $this->lockedConnection($connection);
|
|
DB::table('ai_provider_connections')->where('id', $row->id)->update($columns);
|
|
$this->normalizeDefault();
|
|
}, 3);
|
|
|
|
return response()->json(['data' => $this->payload($this->connection($connection))]);
|
|
}
|
|
|
|
public function destroy(Request $request, string $connection): JsonResponse
|
|
{
|
|
$this->author($request);
|
|
DB::transaction(function () use ($connection): void {
|
|
$this->lockOrganization();
|
|
$row = $this->lockedConnection($connection);
|
|
DB::table('ai_provider_connections')->where('id', $row->id)->delete();
|
|
$this->normalizeDefault();
|
|
}, 3);
|
|
|
|
return response()->json(status: 204);
|
|
}
|
|
|
|
public function makeDefault(Request $request, string $connection): JsonResponse
|
|
{
|
|
$this->author($request);
|
|
DB::transaction(function () use ($connection): void {
|
|
$this->lockOrganization();
|
|
$row = $this->lockedConnection($connection);
|
|
abort_unless($row->enabled, 422, 'اتصال غیرفعال نمیتواند پیشفرض باشد.');
|
|
$this->normalizeDefault($row->id);
|
|
}, 3);
|
|
|
|
return response()->json(['data' => $this->payload($this->connection($connection))]);
|
|
}
|
|
|
|
public function test(Request $request, string $connection): JsonResponse
|
|
{
|
|
$this->author($request);
|
|
$row = $this->connection($connection);
|
|
$result = $this->probe($row);
|
|
DB::table('ai_provider_connections')->where('id', $row->id)->update(['last_status' => $result['connected'] ? 'connected' : 'failed', 'last_latency_ms' => $result['latencyMs'], 'last_error' => $result['connected'] ? null : $result['message'], 'last_tested_at' => now(), 'updated_at' => now()]);
|
|
|
|
return response()->json(['data' => $result]);
|
|
}
|
|
|
|
public function discover(Request $request, string $connection): JsonResponse
|
|
{
|
|
$this->author($request);
|
|
$row = $this->connection($connection);
|
|
try {
|
|
$response = $this->client($row)->get(rtrim($row->base_url, '/').'/models');
|
|
abort_unless($response->successful(), 422, 'فهرست مدلها از ارائهدهنده دریافت نشد.');
|
|
$models = collect($response->json('data', []))->map(fn ($model) => is_array($model) ? ($model['id'] ?? null) : null)->filter()->unique()->sort()->values()->all();
|
|
if ($models === []) {
|
|
throw ValidationException::withMessages(['connection' => ['ارائهدهنده هیچ مدل قابل استفادهای برنگرداند.']]);
|
|
}
|
|
DB::table('ai_provider_connections')->where('id', $row->id)->update(['models' => json_encode($models), 'default_model' => in_array($row->default_model, $models, true) ? $row->default_model : $models[0], 'last_status' => 'connected', 'last_error' => null, 'last_tested_at' => now(), 'updated_at' => now()]);
|
|
|
|
return response()->json(['data' => ['models' => $models]]);
|
|
} catch (ValidationException $exception) {
|
|
throw $exception;
|
|
} catch (\Throwable) {
|
|
throw ValidationException::withMessages(['connection' => ['کشف مدلها ناموفق بود؛ Endpoint و کلید دسترسی را بررسی کنید.']]);
|
|
}
|
|
}
|
|
|
|
private function validated(Request $request): array
|
|
{
|
|
$data = $request->validate([
|
|
'name' => ['required', 'string', 'max:160'],
|
|
'provider' => ['required', Rule::in(array_keys($this->providers()))],
|
|
'mode' => ['required', Rule::in(['local', 'online'])],
|
|
'baseUrl' => ['required', 'url:http,https', 'max:1000'],
|
|
'apiKey' => ['nullable', 'string', 'max:4000'], 'clearApiKey' => ['nullable', 'boolean'],
|
|
'defaultModel' => ['nullable', 'string', 'max:255'], 'timeoutSeconds' => ['required', 'integer', 'between:10,600'], 'enabled' => ['required', 'boolean'],
|
|
]);
|
|
if ($data['mode'] === 'online' && ! str_starts_with($data['baseUrl'], 'https://')) {
|
|
throw ValidationException::withMessages(['baseUrl' => ['اتصال آنلاین باید از HTTPS استفاده کند.']]);
|
|
}
|
|
try {
|
|
$this->endpointPolicy->assertAllowed($data['provider'], $data['mode'], $data['baseUrl']);
|
|
} catch (InvalidArgumentException $exception) {
|
|
throw ValidationException::withMessages(['baseUrl' => [$exception->getMessage()]]);
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
private function columns(array $data): array
|
|
{
|
|
return ['name' => trim($data['name']), 'provider' => $data['provider'], 'mode' => $data['mode'], 'base_url' => rtrim($data['baseUrl'], '/'), 'default_model' => $data['defaultModel'] ?: null, 'timeout_seconds' => $data['timeoutSeconds'], 'enabled' => $data['enabled']];
|
|
}
|
|
|
|
private function connection(string $id): object
|
|
{
|
|
$row = DB::table('ai_provider_connections')->where('organization_id', $this->tenant->id())->where('id', $id)->first();
|
|
abort_unless($row, 404);
|
|
|
|
return $row;
|
|
}
|
|
|
|
private function lockedConnection(string $id): object
|
|
{
|
|
$row = DB::table('ai_provider_connections')
|
|
->where('organization_id', $this->tenant->id())
|
|
->where('id', $id)
|
|
->lockForUpdate()
|
|
->first();
|
|
abort_unless($row, 404);
|
|
|
|
return $row;
|
|
}
|
|
|
|
private function lockOrganization(): void
|
|
{
|
|
DB::table('organizations')->where('id', $this->tenant->id())->lockForUpdate()->first();
|
|
}
|
|
|
|
private function normalizeDefault(?string $preferredId = null): void
|
|
{
|
|
$enabled = DB::table('ai_provider_connections')
|
|
->where('organization_id', $this->tenant->id())
|
|
->where('enabled', true)
|
|
->orderByDesc('is_default')
|
|
->orderBy('created_at')
|
|
->orderBy('id')
|
|
->get(['id', 'is_default']);
|
|
$selectedId = $preferredId && $enabled->contains('id', $preferredId)
|
|
? $preferredId
|
|
: $enabled->firstWhere('is_default', true)?->id ?? $enabled->first()?->id;
|
|
|
|
DB::table('ai_provider_connections')
|
|
->where('organization_id', $this->tenant->id())
|
|
->where('is_default', true)
|
|
->update(['is_default' => false, 'updated_at' => now()]);
|
|
if ($selectedId) {
|
|
DB::table('ai_provider_connections')
|
|
->where('organization_id', $this->tenant->id())
|
|
->where('id', $selectedId)
|
|
->where('enabled', true)
|
|
->update(['is_default' => true, 'updated_at' => now()]);
|
|
}
|
|
}
|
|
|
|
private function payload(object $row): array
|
|
{
|
|
return ['id' => $row->id, 'name' => $row->name, 'provider' => $row->provider, 'mode' => $row->mode, 'baseUrl' => $row->base_url, 'hasApiKey' => filled($row->encrypted_api_key), 'models' => json_decode($row->models ?: '[]', true), 'defaultModel' => $row->default_model, 'timeoutSeconds' => $row->timeout_seconds, 'enabled' => (bool) $row->enabled, 'isDefault' => (bool) $row->is_default, 'lastStatus' => $row->last_status, 'lastLatencyMs' => $row->last_latency_ms, 'lastError' => $row->last_error, 'lastTestedAt' => $row->last_tested_at];
|
|
}
|
|
|
|
private function probe(object $row): array
|
|
{
|
|
$started = microtime(true);
|
|
try {
|
|
$response = $this->client($row)->get(rtrim($row->base_url, '/').'/models');
|
|
$connected = $response->successful();
|
|
|
|
return ['connected' => $connected, 'latencyMs' => (int) round((microtime(true) - $started) * 1000), 'message' => $connected ? 'اتصال با موفقیت برقرار شد.' : 'ارائهدهنده پاسخ معتبر نداد.'];
|
|
} catch (\Throwable) {
|
|
return ['connected' => false, 'latencyMs' => (int) round((microtime(true) - $started) * 1000), 'message' => 'اتصال با ارائهدهنده برقرار نشد.'];
|
|
}
|
|
}
|
|
|
|
private function client(object $row): PendingRequest
|
|
{
|
|
$client = Http::acceptJson()
|
|
->timeout((int) $row->timeout_seconds)
|
|
->withOptions($this->endpointPolicy->requestOptions($row->provider, $row->mode, $row->base_url));
|
|
if (filled($row->encrypted_api_key)) {
|
|
$client = $client->withToken(Crypt::decryptString($row->encrypted_api_key));
|
|
}
|
|
|
|
return $client;
|
|
}
|
|
|
|
private function providers(): array
|
|
{
|
|
return [
|
|
'openai' => ['label' => 'OpenAI', 'mode' => 'online', 'defaultBaseUrl' => 'https://api.openai.com/v1', 'requiresApiKey' => true],
|
|
'openai_compatible' => ['label' => 'سرویس آنلاین OpenAI Compatible', 'mode' => 'online', 'defaultBaseUrl' => 'https://', 'requiresApiKey' => true],
|
|
'ollama' => ['label' => 'Ollama', 'mode' => 'local', 'defaultBaseUrl' => 'http://127.0.0.1:11434/v1', 'requiresApiKey' => false],
|
|
'lm_studio' => ['label' => 'LM Studio', 'mode' => 'local', 'defaultBaseUrl' => 'http://127.0.0.1:1234/v1', 'requiresApiKey' => false],
|
|
'vllm' => ['label' => 'vLLM', 'mode' => 'local', 'defaultBaseUrl' => 'http://127.0.0.1:8000/v1', 'requiresApiKey' => false],
|
|
'localai' => ['label' => 'LocalAI', 'mode' => 'local', 'defaultBaseUrl' => 'http://127.0.0.1:8080/v1', 'requiresApiKey' => false],
|
|
];
|
|
}
|
|
|
|
private function author(Request $request): void
|
|
{
|
|
abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403);
|
|
}
|
|
}
|