feat: implement AI styling foundation and billing scope rework
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-06 20:01:58 +01:00
parent df00deb0df
commit 36bed12ff9
80 changed files with 8944 additions and 49 deletions

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Services\AiEditing;
use App\Models\AiEditingSetting;
class AiEditingRuntimeConfig
{
public function settings(): AiEditingSetting
{
return AiEditingSetting::current();
}
public function isEnabled(): bool
{
return (bool) $this->settings()->is_enabled;
}
public function defaultProvider(): string
{
return (string) ($this->settings()->default_provider ?: 'runware');
}
public function queueAutoDispatch(): bool
{
return (bool) $this->settings()->queue_auto_dispatch;
}
public function queueName(): string
{
$queueName = trim((string) ($this->settings()->queue_name ?: ''));
return $queueName !== '' ? $queueName : 'default';
}
public function maxPolls(): int
{
return max(1, (int) $this->settings()->queue_max_polls);
}
public function runwareMode(): string
{
$mode = trim((string) ($this->settings()->runware_mode ?: ''));
return $mode !== '' ? $mode : 'live';
}
/**
* @return array<int, string>
*/
public function blockedTerms(): array
{
return array_values(array_filter(array_map(
static fn (mixed $term): string => trim((string) $term),
(array) $this->settings()->blocked_terms
)));
}
public function statusMessage(): ?string
{
$message = trim((string) ($this->settings()->status_message ?? ''));
return $message !== '' ? $message : null;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Services\AiEditing;
use App\Services\AiEditing\Contracts\AiImageProvider;
use App\Services\AiEditing\Providers\NullAiImageProvider;
use App\Services\AiEditing\Providers\RunwareAiImageProvider;
class AiImageProviderManager
{
public function forProvider(string $provider): AiImageProvider
{
return match ($provider) {
'runware' => app(RunwareAiImageProvider::class),
default => app(NullAiImageProvider::class),
};
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Services\AiEditing;
class AiProviderResult
{
/**
* @param array<int, array<string, mixed>> $outputs
* @param array<string, mixed> $requestPayload
* @param array<string, mixed> $responsePayload
*/
public function __construct(
public readonly string $status,
public readonly ?string $providerTaskId = null,
public readonly ?string $failureCode = null,
public readonly ?string $failureMessage = null,
public readonly ?string $safetyState = null,
public readonly array $safetyReasons = [],
public readonly ?float $costUsd = null,
public readonly array $outputs = [],
public readonly array $requestPayload = [],
public readonly array $responsePayload = [],
public readonly ?int $httpStatus = null,
) {}
/**
* @param array<int, array<string, mixed>> $outputs
* @param array<string, mixed> $requestPayload
* @param array<string, mixed> $responsePayload
*/
public static function succeeded(
array $outputs = [],
?float $costUsd = null,
?string $safetyState = null,
array $safetyReasons = [],
array $requestPayload = [],
array $responsePayload = [],
?int $httpStatus = null,
): self {
return new self(
status: 'succeeded',
outputs: $outputs,
costUsd: $costUsd,
safetyState: $safetyState,
safetyReasons: $safetyReasons,
requestPayload: $requestPayload,
responsePayload: $responsePayload,
httpStatus: $httpStatus,
);
}
/**
* @param array<string, mixed> $requestPayload
* @param array<string, mixed> $responsePayload
*/
public static function processing(
string $providerTaskId,
?float $costUsd = null,
array $requestPayload = [],
array $responsePayload = [],
?int $httpStatus = null,
): self {
return new self(
status: 'processing',
providerTaskId: $providerTaskId,
costUsd: $costUsd,
requestPayload: $requestPayload,
responsePayload: $responsePayload,
httpStatus: $httpStatus,
);
}
/**
* @param array<string, mixed> $requestPayload
* @param array<string, mixed> $responsePayload
*/
public static function failed(
string $failureCode,
string $failureMessage,
array $requestPayload = [],
array $responsePayload = [],
?int $httpStatus = null,
): self {
return new self(
status: 'failed',
failureCode: $failureCode,
failureMessage: $failureMessage,
requestPayload: $requestPayload,
responsePayload: $responsePayload,
httpStatus: $httpStatus,
);
}
/**
* @param array<string, mixed> $requestPayload
* @param array<string, mixed> $responsePayload
*/
public static function blocked(
string $failureCode,
string $failureMessage,
?string $safetyState = null,
array $safetyReasons = [],
array $requestPayload = [],
array $responsePayload = [],
?int $httpStatus = null,
): self {
return new self(
status: 'blocked',
failureCode: $failureCode,
failureMessage: $failureMessage,
safetyState: $safetyState,
safetyReasons: $safetyReasons,
requestPayload: $requestPayload,
responsePayload: $responsePayload,
httpStatus: $httpStatus,
);
}
}

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Services\AiEditing;
use App\Models\AiStyle;
use App\Models\Event;
use App\Models\EventPackage;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
class AiStyleAccessService
{
public function __construct(private readonly AiStylingEntitlementService $entitlements) {}
public function canUseStyle(Event $event, AiStyle $style): bool
{
$entitlement = $this->entitlements->resolveForEvent($event);
if (! $entitlement['allowed']) {
return false;
}
$allowedSources = $this->allowedSources($style);
if (! in_array((string) $entitlement['granted_by'], $allowedSources, true)) {
return false;
}
$requiredPackageFeatures = $this->requiredPackageFeatures($style);
if ($requiredPackageFeatures === []) {
return true;
}
$packageFeatures = $this->resolveEventPackageFeatures($event);
foreach ($requiredPackageFeatures as $requiredFeature) {
if (! in_array($requiredFeature, $packageFeatures, true)) {
return false;
}
}
return true;
}
/**
* @param Collection<int, AiStyle> $styles
* @return Collection<int, AiStyle>
*/
public function filterStylesForEvent(Event $event, Collection $styles): Collection
{
return $styles
->filter(fn (AiStyle $style): bool => $this->canUseStyle($event, $style))
->values();
}
/**
* @return array<int, string>
*/
private function allowedSources(AiStyle $style): array
{
$metadataSources = $this->normalizeStringList(Arr::get($style->metadata ?? [], 'entitlements.allowed_sources', []));
if ($metadataSources !== []) {
return $metadataSources;
}
if (is_bool(Arr::get($style->metadata ?? [], 'entitlements.allow_with_addon'))) {
return Arr::get($style->metadata ?? [], 'entitlements.allow_with_addon')
? ['package', 'addon']
: ['package'];
}
return $style->is_premium ? ['package'] : ['package', 'addon'];
}
/**
* @return array<int, string>
*/
private function requiredPackageFeatures(AiStyle $style): array
{
return $this->normalizeStringList(
Arr::get($style->metadata ?? [], 'entitlements.required_package_features', [])
);
}
/**
* @return array<int, string>
*/
private function resolveEventPackageFeatures(Event $event): array
{
$eventPackage = $event->relationLoaded('eventPackage') && $event->eventPackage
? $event->eventPackage
: $event->eventPackage()->with('package')->first();
if (! $eventPackage instanceof EventPackage) {
return [];
}
$package = $eventPackage->relationLoaded('package') ? $eventPackage->package : $eventPackage->package()->first();
return $this->normalizeFeatureList($package?->features);
}
/**
* @return array<int, string>
*/
private function normalizeFeatureList(mixed $value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
$value = $decoded;
}
}
if (! is_array($value)) {
return [];
}
if (array_is_list($value)) {
return $this->normalizeStringList($value);
}
return $this->normalizeStringList(array_keys(array_filter($value, static fn (mixed $enabled): bool => (bool) $enabled)));
}
/**
* @return array<int, string>
*/
private function normalizeStringList(array $values): array
{
return array_values(array_unique(array_filter(array_map(
static fn (mixed $value): string => trim((string) $value),
$values
))));
}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace App\Services\AiEditing;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
class AiStylingEntitlementService
{
public function packageFeatureKey(): string
{
$featureKey = trim((string) config('ai-editing.entitlements.package_feature', 'ai_styling'));
return $featureKey !== '' ? $featureKey : 'ai_styling';
}
/**
* @return array<int, string>
*/
public function addonKeys(): array
{
return array_values(array_filter(array_map(
static fn (mixed $key): string => trim((string) $key),
(array) config('ai-editing.entitlements.addon_keys', ['ai_styling_unlock'])
)));
}
public function lockedMessage(): string
{
$message = trim((string) config('ai-editing.entitlements.locked_message', ''));
if ($message !== '') {
return $message;
}
return 'AI editing requires the Premium package or the AI Styling add-on.';
}
/**
* @return array{
* allowed: bool,
* granted_by: 'package'|'addon'|null,
* required_feature: string,
* addon_keys: array<int, string>
* }
*/
public function resolveForEvent(Event $event): array
{
$requiredFeature = $this->packageFeatureKey();
$addonKeys = $this->addonKeys();
$eventPackage = $this->resolveEventPackage($event);
if (! $eventPackage) {
return [
'allowed' => false,
'granted_by' => null,
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
if ($this->packageGrantsAccess($eventPackage->package, $requiredFeature)) {
return [
'allowed' => true,
'granted_by' => 'package',
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
if ($this->addonGrantsAccess($eventPackage, $addonKeys, $requiredFeature)) {
return [
'allowed' => true,
'granted_by' => 'addon',
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
return [
'allowed' => false,
'granted_by' => null,
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
public function hasAccessForEvent(Event $event): bool
{
return (bool) $this->resolveForEvent($event)['allowed'];
}
private function resolveEventPackage(Event $event): ?EventPackage
{
$event->loadMissing('eventPackage.package', 'eventPackage.addons');
if ($event->eventPackage) {
return $event->eventPackage;
}
return $event->eventPackages()
->with(['package', 'addons'])
->orderByDesc('purchased_at')
->orderByDesc('id')
->first();
}
private function packageGrantsAccess(?Package $package, string $requiredFeature): bool
{
if (! $package) {
return false;
}
return in_array($requiredFeature, $this->normalizeFeatureList($package->features), true);
}
/**
* @param array<int, string> $addonKeys
*/
private function addonGrantsAccess(EventPackage $eventPackage, array $addonKeys, string $requiredFeature): bool
{
$addons = $eventPackage->relationLoaded('addons')
? $eventPackage->addons
: $eventPackage->addons()
->where('status', 'completed')
->get();
return $addons->contains(function (EventPackageAddon $addon) use ($addonKeys, $requiredFeature): bool {
if (! $this->addonIsActive($addon)) {
return false;
}
if ($addonKeys !== [] && in_array((string) $addon->addon_key, $addonKeys, true)) {
return true;
}
$metadataFeatures = $this->normalizeFeatureList(
Arr::get($addon->metadata ?? [], 'entitlements.features', Arr::get($addon->metadata ?? [], 'features', []))
);
return in_array($requiredFeature, $metadataFeatures, true);
});
}
private function addonIsActive(EventPackageAddon $addon): bool
{
if ($addon->status !== 'completed') {
return false;
}
$expiryCandidates = [
Arr::get($addon->metadata ?? [], 'entitlements.expires_at'),
Arr::get($addon->metadata ?? [], 'expires_at'),
Arr::get($addon->metadata ?? [], 'valid_until'),
];
foreach ($expiryCandidates as $candidate) {
if (! is_string($candidate) || trim($candidate) === '') {
continue;
}
try {
$expiresAt = CarbonImmutable::parse($candidate);
} catch (\Throwable) {
continue;
}
if ($expiresAt->isPast()) {
return false;
}
}
return true;
}
/**
* @return array<int, string>
*/
private function normalizeFeatureList(mixed $value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
$value = $decoded;
}
}
if (! is_array($value)) {
return [];
}
if (array_is_list($value)) {
return array_values(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
$value
)));
}
return array_values(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
array_keys(array_filter($value, static fn (mixed $enabled): bool => (bool) $enabled))
)));
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Services\AiEditing;
use App\Models\AiEditRequest;
use App\Models\AiUsageLedger;
use App\Models\Event;
use Illuminate\Support\Facades\DB;
class AiUsageLedgerService
{
public function __construct(private readonly AiStylingEntitlementService $entitlements) {}
/**
* @param array<string, mixed> $metadata
*/
public function recordDebitForRequest(AiEditRequest $request, ?float $costUsd = null, array $metadata = []): AiUsageLedger
{
return DB::transaction(function () use ($request, $costUsd, $metadata): AiUsageLedger {
$lockedRequest = AiEditRequest::query()
->whereKey($request->id)
->lockForUpdate()
->firstOrFail();
$existing = AiUsageLedger::query()
->where('request_id', $lockedRequest->id)
->where('entry_type', AiUsageLedger::TYPE_DEBIT)
->first();
if ($existing) {
return $existing;
}
$resolvedCost = $costUsd;
if ($resolvedCost === null || $resolvedCost < 0) {
$resolvedCost = (float) config('ai-editing.billing.default_unit_cost_usd', 0.01);
}
$event = Event::query()->find($lockedRequest->event_id);
$entitlement = $event ? $this->entitlements->resolveForEvent($event) : [
'allowed' => false,
'granted_by' => null,
];
$packageContext = $entitlement['granted_by'] === 'package'
? 'package_included'
: ($entitlement['granted_by'] === 'addon' ? 'addon_unlock' : 'unentitled');
return AiUsageLedger::query()->create([
'tenant_id' => $lockedRequest->tenant_id,
'event_id' => $lockedRequest->event_id,
'request_id' => $lockedRequest->id,
'entry_type' => AiUsageLedger::TYPE_DEBIT,
'quantity' => 1,
'unit_cost_usd' => $resolvedCost,
'amount_usd' => $resolvedCost,
'currency' => 'USD',
'package_context' => $packageContext,
'recorded_at' => now(),
'metadata' => array_merge([
'provider' => $lockedRequest->provider,
'provider_model' => $lockedRequest->provider_model,
'granted_by' => $entitlement['granted_by'],
], $metadata),
]);
});
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Services\AiEditing\Contracts;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiProviderResult;
interface AiImageProvider
{
public function submit(AiEditRequest $request): AiProviderResult;
public function poll(AiEditRequest $request, string $providerTaskId): AiProviderResult;
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Services\AiEditing;
use App\Models\AiStyle;
use App\Models\Event;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
class EventAiEditingPolicyService
{
/**
* @return array{
* enabled: bool,
* allow_custom_prompt: bool,
* allowed_style_keys: array<int, string>,
* policy_message: ?string
* }
*/
public function resolve(Event $event): array
{
$settings = is_array($event->settings) ? $event->settings : [];
$aiSettings = Arr::get($settings, 'ai_editing', []);
$aiSettings = is_array($aiSettings) ? $aiSettings : [];
$enabled = array_key_exists('enabled', $aiSettings)
? (bool) $aiSettings['enabled']
: true;
$allowCustomPrompt = array_key_exists('allow_custom_prompt', $aiSettings)
? (bool) $aiSettings['allow_custom_prompt']
: true;
$allowedStyleKeys = collect($aiSettings['allowed_style_keys'] ?? [])
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(fn (string $value): string => trim($value))
->unique()
->values()
->all();
$policyMessage = trim((string) ($aiSettings['policy_message'] ?? ''));
return [
'enabled' => $enabled,
'allow_custom_prompt' => $allowCustomPrompt,
'allowed_style_keys' => $allowedStyleKeys,
'policy_message' => $policyMessage !== '' ? $policyMessage : null,
];
}
public function isEnabled(Event $event): bool
{
return (bool) $this->resolve($event)['enabled'];
}
public function isStyleAllowed(Event $event, ?AiStyle $style): bool
{
$policy = $this->resolve($event);
if ($style === null) {
return (bool) $policy['allow_custom_prompt'];
}
/** @var array<int, string> $allowedStyleKeys */
$allowedStyleKeys = $policy['allowed_style_keys'];
if ($allowedStyleKeys === []) {
return true;
}
return in_array($style->key, $allowedStyleKeys, true);
}
/**
* @param Collection<int, AiStyle> $styles
* @return Collection<int, AiStyle>
*/
public function filterStyles(Event $event, Collection $styles): Collection
{
$policy = $this->resolve($event);
/** @var array<int, string> $allowedStyleKeys */
$allowedStyleKeys = $policy['allowed_style_keys'];
if ($allowedStyleKeys === []) {
return $styles->values();
}
return $styles
->filter(fn (AiStyle $style): bool => in_array($style->key, $allowedStyleKeys, true))
->values();
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Services\AiEditing\Providers;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiProviderResult;
use App\Services\AiEditing\Contracts\AiImageProvider;
class NullAiImageProvider implements AiImageProvider
{
public function submit(AiEditRequest $request): AiProviderResult
{
return AiProviderResult::failed(
'provider_not_supported',
sprintf('The AI provider "%s" is not supported.', $request->provider)
);
}
public function poll(AiEditRequest $request, string $providerTaskId): AiProviderResult
{
return AiProviderResult::failed(
'provider_not_supported',
sprintf('The AI provider "%s" is not supported.', $request->provider)
);
}
}

View File

@@ -0,0 +1,287 @@
<?php
namespace App\Services\AiEditing\Providers;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiProviderResult;
use App\Services\AiEditing\Contracts\AiImageProvider;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Throwable;
class RunwareAiImageProvider implements AiImageProvider
{
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig) {}
public function submit(AiEditRequest $request): AiProviderResult
{
if ($this->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;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Services\AiEditing\Safety;
class AiSafetyDecision
{
/**
* @param array<int, string> $reasonCodes
*/
public function __construct(
public readonly bool $blocked,
public readonly string $state,
public readonly array $reasonCodes = [],
public readonly ?string $failureCode = null,
public readonly ?string $failureMessage = null,
) {}
public static function passed(): self
{
return new self(
blocked: false,
state: 'passed',
);
}
/**
* @param array<int, string> $reasonCodes
*/
public static function blocked(array $reasonCodes, string $failureCode, string $failureMessage): self
{
return new self(
blocked: true,
state: 'blocked',
reasonCodes: array_values(array_unique($reasonCodes)),
failureCode: $failureCode,
failureMessage: $failureMessage,
);
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Services\AiEditing\Safety;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiProviderResult;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class AiSafetyPolicyService
{
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig) {}
public function evaluatePrompt(?string $prompt, ?string $negativePrompt): AiSafetyDecision
{
$blockedTerms = array_filter(array_map(
static fn (mixed $term): string => Str::lower(trim((string) $term)),
$this->runtimeConfig->blockedTerms()
));
if ($blockedTerms === []) {
return AiSafetyDecision::passed();
}
$fullPrompt = Str::lower(trim(sprintf('%s %s', (string) $prompt, (string) $negativePrompt)));
if ($fullPrompt === '') {
return AiSafetyDecision::passed();
}
$matched = [];
foreach ($blockedTerms as $term) {
if ($term !== '' && str_contains($fullPrompt, $term)) {
$matched[] = 'prompt_blocked_term';
}
}
if ($matched === []) {
return AiSafetyDecision::passed();
}
return AiSafetyDecision::blocked(
reasonCodes: $matched,
failureCode: 'prompt_policy_blocked',
failureMessage: 'The provided prompt violates the AI editing safety policy.'
);
}
public function evaluateProviderOutput(AiProviderResult $result): AiSafetyDecision
{
if ($result->status === 'blocked') {
return AiSafetyDecision::blocked(
reasonCodes: $result->safetyReasons !== [] ? $result->safetyReasons : ['provider_blocked'],
failureCode: $result->failureCode ?: 'output_policy_blocked',
failureMessage: $result->failureMessage ?: 'The generated output was blocked by safety policy.'
);
}
if ($result->safetyState === 'blocked') {
return AiSafetyDecision::blocked(
reasonCodes: $result->safetyReasons !== [] ? $result->safetyReasons : ['provider_nsfw_content'],
failureCode: 'output_policy_blocked',
failureMessage: 'The generated output was blocked by safety policy.'
);
}
$payloadItems = (array) Arr::get($result->responsePayload, 'data', []);
foreach ($payloadItems as $item) {
if (! is_array($item)) {
continue;
}
if ($this->toBool(Arr::get($item, 'NSFWContent')) || $this->toBool(Arr::get($item, 'nsfwContent'))) {
return AiSafetyDecision::blocked(
reasonCodes: ['provider_nsfw_content'],
failureCode: 'output_policy_blocked',
failureMessage: 'The generated output was blocked by safety policy.'
);
}
}
return AiSafetyDecision::passed();
}
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;
}
}