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); } }