isFakeMode()) { return $this->fakeResult($request); } $apiKey = $this->apiKey(); if (! $apiKey) { return AiProviderResult::failed( 'provider_not_configured', 'Runware API key is not configured.' ); } $payload = [ [ 'taskType' => 'imageInference', 'taskUUID' => (string) Str::uuid(), 'positivePrompt' => (string) ($request->prompt ?? ''), 'negativePrompt' => (string) ($request->negative_prompt ?? ''), 'outputType' => 'URL', 'outputFormat' => 'JPG', 'includeCost' => true, 'safety' => [ 'checkContent' => true, ], ], ]; if (is_string($request->provider_model) && $request->provider_model !== '') { $payload[0]['model'] = $request->provider_model; } if (is_string($request->input_image_path) && $request->input_image_path !== '') { $payload[0]['seedImage'] = $request->input_image_path; } try { $response = Http::withToken($apiKey) ->acceptJson() ->timeout((int) config('services.runware.timeout', 90)) ->post($this->baseUrl(), $payload); $body = (array) $response->json(); $data = Arr::first((array) ($body['data'] ?? []), []); $providerTaskId = (string) ($data['taskUUID'] ?? ''); $status = strtolower((string) ($data['status'] ?? '')); $cost = is_numeric($data['cost'] ?? null) ? (float) $data['cost'] : null; $imageUrl = $data['imageURL'] ?? $data['outputUrl'] ?? $data['url'] ?? null; $providerNsfw = $this->toBool($data['NSFWContent'] ?? null) || $this->toBool($data['nsfwContent'] ?? null); if (is_string($imageUrl) && $imageUrl !== '') { if ($providerNsfw) { return AiProviderResult::blocked( failureCode: 'provider_nsfw_content', failureMessage: 'Provider flagged generated content as unsafe.', safetyState: 'blocked', safetyReasons: ['provider_nsfw_content'], requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } return AiProviderResult::succeeded( outputs: [[ 'provider_url' => $imageUrl, 'provider_asset_id' => $providerTaskId !== '' ? $providerTaskId : null, 'mime_type' => 'image/jpeg', ]], costUsd: $cost, safetyState: 'passed', requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } if ($providerTaskId !== '' || $status === 'processing') { return AiProviderResult::processing( providerTaskId: $providerTaskId !== '' ? $providerTaskId : (string) Str::uuid(), costUsd: $cost, requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } return AiProviderResult::failed( 'provider_unexpected_response', 'Runware returned an unexpected response format.', requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } catch (Throwable $exception) { return AiProviderResult::failed( 'provider_exception', $exception->getMessage(), requestPayload: ['tasks' => $payload], ); } } public function poll(AiEditRequest $request, string $providerTaskId): AiProviderResult { if ($this->isFakeMode()) { return $this->fakeResult($request, $providerTaskId); } $apiKey = $this->apiKey(); if (! $apiKey) { return AiProviderResult::failed( 'provider_not_configured', 'Runware API key is not configured.' ); } $payload = [[ 'taskType' => 'getResponse', 'taskUUID' => $providerTaskId, 'includeCost' => true, ]]; try { $response = Http::withToken($apiKey) ->acceptJson() ->timeout((int) config('services.runware.timeout', 90)) ->post($this->baseUrl(), $payload); $body = (array) $response->json(); $data = Arr::first((array) ($body['data'] ?? []), []); $status = strtolower((string) ($data['status'] ?? '')); $cost = is_numeric($data['cost'] ?? null) ? (float) $data['cost'] : null; $imageUrl = $data['imageURL'] ?? $data['outputUrl'] ?? $data['url'] ?? null; $providerNsfw = $this->toBool($data['NSFWContent'] ?? null) || $this->toBool($data['nsfwContent'] ?? null); if (is_string($imageUrl) && $imageUrl !== '') { if ($providerNsfw) { return AiProviderResult::blocked( failureCode: 'provider_nsfw_content', failureMessage: 'Provider flagged generated content as unsafe.', safetyState: 'blocked', safetyReasons: ['provider_nsfw_content'], requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } return AiProviderResult::succeeded( outputs: [[ 'provider_url' => $imageUrl, 'provider_asset_id' => $providerTaskId, 'mime_type' => 'image/jpeg', ]], costUsd: $cost, safetyState: 'passed', requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } if ($status === 'processing' || $status === '') { return AiProviderResult::processing( providerTaskId: $providerTaskId, costUsd: $cost, requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } if (in_array($status, ['failed', 'error'], true)) { return AiProviderResult::failed( 'provider_failed', (string) ($data['errorMessage'] ?? 'Runware reported a failed job.'), requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } return AiProviderResult::failed( 'provider_unexpected_response', 'Runware returned an unexpected poll response format.', requestPayload: ['tasks' => $payload], responsePayload: $body, httpStatus: $response->status(), ); } catch (Throwable $exception) { return AiProviderResult::failed( 'provider_exception', $exception->getMessage(), requestPayload: ['tasks' => $payload], ); } } private function fakeResult(AiEditRequest $request, ?string $taskId = null): AiProviderResult { $resolvedTaskId = $taskId ?: 'runware-fake-'.Str::uuid()->toString(); $fakeNsfw = (bool) Arr::get($request->metadata ?? [], 'fake_nsfw', false); return AiProviderResult::succeeded( outputs: [[ 'provider_url' => sprintf('https://cdn.example.invalid/ai/%s.jpg', $resolvedTaskId), 'provider_asset_id' => $resolvedTaskId, 'mime_type' => 'image/jpeg', 'width' => 1024, 'height' => 1024, ]], costUsd: 0.01, safetyState: $fakeNsfw ? 'blocked' : 'passed', safetyReasons: $fakeNsfw ? ['provider_nsfw_content'] : [], requestPayload: [ 'prompt' => $request->prompt, 'provider_model' => $request->provider_model, 'task_id' => $resolvedTaskId, ], responsePayload: [ 'mode' => 'fake', 'status' => 'succeeded', 'data' => [ [ 'taskUUID' => $resolvedTaskId, 'NSFWContent' => $fakeNsfw, ], ], ] ); } private function isFakeMode(): bool { return $this->runtimeConfig->runwareMode() === 'fake'; } private function apiKey(): ?string { $apiKey = config('services.runware.api_key'); return is_string($apiKey) && $apiKey !== '' ? $apiKey : null; } private function baseUrl(): string { $base = (string) config('services.runware.base_url', 'https://api.runware.ai/v1'); return rtrim($base, '/'); } private function toBool(mixed $value): bool { if (is_bool($value)) { return $value; } if (is_numeric($value)) { return (int) $value === 1; } if (is_string($value)) { return in_array(Str::lower(trim($value)), ['1', 'true', 'yes'], true); } return false; } }