, * constraints: array * }> */ public function search(string $search, ?int $limit = null): array { $normalizedSearch = trim($search); $minimumSearchLength = max(1, (int) config('ai-editing.providers.runware.model_search_min_chars', 2)); if (Str::length($normalizedSearch) < $minimumSearchLength) { return []; } $apiKey = $this->apiKey(); if (! $apiKey) { return []; } $resolvedLimit = $this->resolveLimit($limit); $cacheTtl = max(30, (int) config('ai-editing.providers.runware.model_search_cache_seconds', 300)); $cacheKey = sprintf( 'ai_editing.runware.model_search.%s', sha1(Str::lower($normalizedSearch).'|'.$resolvedLimit) ); return Cache::remember( $cacheKey, now()->addSeconds($cacheTtl), fn (): array => $this->performSearch($normalizedSearch, $resolvedLimit, $apiKey), ); } /** * @return array */ public function searchOptions(string $search, ?int $limit = null): array { return collect($this->search($search, $limit)) ->mapWithKeys(fn (array $model): array => [$model['air'] => $this->formatLabel($model)]) ->all(); } public function labelForModel(mixed $air): ?string { if (! is_string($air) || trim($air) === '') { return null; } $model = $this->findByAir($air); return $model ? $this->formatLabel($model) : trim($air); } /** * @return array{ * air: string, * name: string, * architecture: ?string, * category: ?string, * defaults: array, * constraints: array * }|null */ public function findByAir(string $air): ?array { $normalizedAir = trim($air); if ($normalizedAir === '') { return null; } $results = $this->search($normalizedAir, 50); foreach ($results as $model) { if (Str::lower($model['air']) === Str::lower($normalizedAir)) { return $model; } } return null; } /** * @return array, * constraints: array * }> */ private function performSearch(string $search, int $limit, string $apiKey): array { $payload = [[ 'taskType' => 'modelSearch', 'taskUUID' => (string) Str::uuid(), 'search' => $search, 'limit' => $limit, 'offset' => 0, ]]; $response = Http::withToken($apiKey) ->acceptJson() ->timeout((int) config('services.runware.timeout', 90)) ->post($this->baseUrl(), $payload); if (! $response->successful()) { return []; } $body = (array) $response->json(); $items = (array) ($body['data'] ?? []); $models = []; foreach ($items as $item) { if (! is_array($item)) { continue; } $normalized = $this->normalizeModel($item); if (! is_array($normalized)) { continue; } $models[$normalized['air']] = $normalized; } return array_values($models); } /** * @param array $item * @return array{ * air: string, * name: string, * architecture: ?string, * category: ?string, * defaults: array, * constraints: array * }|null */ private function normalizeModel(array $item): ?array { $air = trim((string) (Arr::get($item, 'air') ?? Arr::get($item, 'model') ?? '')); if ($air === '') { return null; } $name = trim((string) (Arr::get($item, 'name') ?? Arr::get($item, 'title') ?? $air)); $architecture = $this->normalizeOptionalString((string) Arr::get($item, 'architecture', '')); $category = $this->normalizeOptionalString((string) Arr::get($item, 'category', '')); $defaults = [ 'width' => $this->normalizeInteger(Arr::get($item, 'defaultWidth')), 'height' => $this->normalizeInteger(Arr::get($item, 'defaultHeight')), 'steps' => $this->normalizeInteger(Arr::get($item, 'defaultSteps')), 'cfg_scale' => $this->normalizeFloat(Arr::get($item, 'defaultCFG')), ]; $constraints = [ 'min_width' => $this->normalizeInteger(Arr::get($item, 'minWidth')), 'max_width' => $this->normalizeInteger(Arr::get($item, 'maxWidth')), 'width_step' => $this->normalizeInteger(Arr::get($item, 'widthStep')), 'min_height' => $this->normalizeInteger(Arr::get($item, 'minHeight')), 'max_height' => $this->normalizeInteger(Arr::get($item, 'maxHeight')), 'height_step' => $this->normalizeInteger(Arr::get($item, 'heightStep')), 'min_steps' => $this->normalizeInteger(Arr::get($item, 'minSteps')), 'max_steps' => $this->normalizeInteger(Arr::get($item, 'maxSteps')), 'min_cfg_scale' => $this->normalizeFloat(Arr::get($item, 'minCFG')), 'max_cfg_scale' => $this->normalizeFloat(Arr::get($item, 'maxCFG')), 'min_strength' => $this->normalizeFloat(Arr::get($item, 'minStrength')), 'max_strength' => $this->normalizeFloat(Arr::get($item, 'maxStrength')), ]; return [ 'air' => $air, 'name' => $name !== '' ? $name : $air, 'architecture' => $architecture, 'category' => $category, 'defaults' => $defaults, 'constraints' => $constraints, ]; } /** * @param array{ * air: string, * name: string, * architecture: ?string, * category: ?string, * defaults: array, * constraints: array * } $model */ private function formatLabel(array $model): string { $parts = [$model['name']]; if ($model['architecture']) { $parts[] = $model['architecture']; } $parts[] = $model['air']; return implode(' | ', array_filter($parts, static fn (mixed $value): bool => is_string($value) && trim($value) !== '')); } private function resolveLimit(?int $limit): int { $defaultLimit = max(1, (int) config('ai-editing.providers.runware.model_search_limit', 25)); $resolved = $limit ?? $defaultLimit; return max(1, min(100, $resolved)); } private function apiKey(): ?string { $apiKey = config('services.runware.api_key'); return is_string($apiKey) && trim($apiKey) !== '' ? trim($apiKey) : null; } private function baseUrl(): string { return rtrim((string) config('services.runware.base_url', 'https://api.runware.ai/v1'), '/'); } private function normalizeOptionalString(string $value): ?string { $trimmed = trim($value); return $trimmed !== '' ? $trimmed : null; } private function normalizeInteger(mixed $value): ?int { return is_numeric($value) ? (int) $value : null; } private function normalizeFloat(mixed $value): ?float { return is_numeric($value) ? (float) $value : null; } }