Laravel app with a cost-tracked Runware.ai integration (image, video, text generation), a hard monthly EUR budget cap with live USD/EUR FX conversion, and a locally-maintained model/pricing table since Runware's model-search API doesn't expose pricing.
252 lines
8.5 KiB
PHP
252 lines
8.5 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Runware;
|
|
|
|
use App\Models\Generation;
|
|
use App\Models\RunwareModel;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Orchestrates a single Runware generation end to end: resolve the model,
|
|
* pre-flight budget check, call the API, persist the result, reconcile the
|
|
* real cost. Image and text run synchronously; video is long-running and is
|
|
* driven by App\Jobs\GenerateVideoJob via queueVideo()/processVideo().
|
|
*/
|
|
class GenerationService
|
|
{
|
|
public function __construct(
|
|
protected RunwareClient $client,
|
|
protected BudgetGuard $budget,
|
|
protected CostEstimator $estimator,
|
|
protected CurrencyRateService $rates,
|
|
) {}
|
|
|
|
public function resolveModel(string $modality, ?string $airIdOverride = null): RunwareModel
|
|
{
|
|
if ($airIdOverride) {
|
|
$model = RunwareModel::query()
|
|
->where('modality', $modality)
|
|
->where('air_id', $airIdOverride)
|
|
->where('active', true)
|
|
->first();
|
|
|
|
if ($model) {
|
|
return $model;
|
|
}
|
|
}
|
|
|
|
return RunwareModel::defaultFor($modality)
|
|
?? throw new RuntimeException("No default {$modality} model configured. Add one under Runware Models.");
|
|
}
|
|
|
|
public function createImage(string $prompt, array $params = [], ?string $modelOverride = null): Generation
|
|
{
|
|
$model = $this->resolveModel('image', $modelOverride);
|
|
$params = array_merge(['width' => 1024, 'height' => 1024, 'numberResults' => 1], $params);
|
|
|
|
$generation = Generation::create([
|
|
'type' => 'image',
|
|
'model' => $model->air_id,
|
|
'prompt' => $prompt,
|
|
'params' => $params,
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
$this->budget->ensureCanSpend($this->estimator->estimateUsd($model, $params));
|
|
|
|
try {
|
|
$taskUUID = (string) Str::uuid();
|
|
|
|
$results = $this->client->runTasks([array_merge([
|
|
'taskType' => 'imageInference',
|
|
'taskUUID' => $taskUUID,
|
|
'model' => $model->air_id,
|
|
'positivePrompt' => $prompt,
|
|
'includeCost' => true,
|
|
], $params)]);
|
|
|
|
$result = $results[0];
|
|
$path = $this->downloadTo("generations/images/{$taskUUID}", $result['imageURL']);
|
|
|
|
$generation->update([
|
|
'status' => 'completed',
|
|
'runware_task_uuid' => $taskUUID,
|
|
'output_path' => $path,
|
|
...$this->costFields($result['cost'] ?? null),
|
|
]);
|
|
} catch (Throwable $e) {
|
|
$generation->update(['status' => 'failed', 'error' => $e->getMessage()]);
|
|
throw $e;
|
|
}
|
|
|
|
return $generation->fresh();
|
|
}
|
|
|
|
public function createText(string $prompt, array $params = [], ?string $modelOverride = null): Generation
|
|
{
|
|
$model = $this->resolveModel('text', $modelOverride);
|
|
$params = array_merge(['max_completion_tokens' => 1024], $params);
|
|
|
|
$generation = Generation::create([
|
|
'type' => 'text',
|
|
'model' => $model->air_id,
|
|
'prompt' => $prompt,
|
|
'params' => $params,
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
$this->budget->ensureCanSpend($this->estimator->estimateUsd($model, $params));
|
|
|
|
try {
|
|
$response = $this->client->chatCompletion([
|
|
'model' => $model->air_id,
|
|
'messages' => [['role' => 'user', 'content' => $prompt]],
|
|
...$params,
|
|
]);
|
|
|
|
$content = $response['choices'][0]['message']['content'] ?? throw new RuntimeException(
|
|
'Unexpected Runware chat response: '.json_encode($response)
|
|
);
|
|
|
|
$costUsd = $response['usage']['cost'] ?? null;
|
|
$estimated = false;
|
|
|
|
if ($costUsd === null) {
|
|
// Runware doesn't always return usage.cost for non-streaming
|
|
// chat completions; fall back to a token-based estimate and
|
|
// flag it so it's visible it isn't Runware's own figure.
|
|
$tokens = $response['usage']['total_tokens'] ?? $params['max_completion_tokens'];
|
|
$costUsd = (float) $model->price_usd * ($tokens / 1_000_000);
|
|
$estimated = true;
|
|
}
|
|
|
|
$generation->update([
|
|
'status' => 'completed',
|
|
'output_text' => $content,
|
|
...$this->costFields($costUsd, $estimated),
|
|
]);
|
|
} catch (Throwable $e) {
|
|
$generation->update(['status' => 'failed', 'error' => $e->getMessage()]);
|
|
throw $e;
|
|
}
|
|
|
|
return $generation->fresh();
|
|
}
|
|
|
|
public function queueVideo(string $prompt, array $params = [], ?string $modelOverride = null): Generation
|
|
{
|
|
$model = $this->resolveModel('video', $modelOverride);
|
|
$params = array_merge(['width' => 1280, 'height' => 720, 'duration' => 5], $params);
|
|
|
|
$this->budget->ensureCanSpend($this->estimator->estimateUsd($model, $params));
|
|
|
|
return Generation::create([
|
|
'type' => 'video',
|
|
'model' => $model->air_id,
|
|
'prompt' => $prompt,
|
|
'params' => $params,
|
|
'status' => 'pending',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Runs the actual video submit+poll+download flow. Called from
|
|
* App\Jobs\GenerateVideoJob, not directly from the controller, since
|
|
* video generation can take minutes.
|
|
*/
|
|
public function processVideo(Generation $generation): void
|
|
{
|
|
$model = RunwareModel::query()
|
|
->where('modality', 'video')
|
|
->where('air_id', $generation->model)
|
|
->firstOrFail();
|
|
|
|
// Re-check right before spending: time has passed since this job was
|
|
// queued and other generations may have consumed the budget since.
|
|
$this->budget->ensureCanSpend($this->estimator->estimateUsd($model, $generation->params ?? []));
|
|
|
|
$generation->update(['status' => 'processing']);
|
|
|
|
$taskUUID = (string) Str::uuid();
|
|
|
|
$this->client->runTasks([array_merge([
|
|
'taskType' => 'videoInference',
|
|
'taskUUID' => $taskUUID,
|
|
'model' => $model->air_id,
|
|
'positivePrompt' => $generation->prompt,
|
|
'deliveryMethod' => 'async',
|
|
'includeCost' => true,
|
|
], $generation->params ?? [])]);
|
|
|
|
$result = $this->pollUntilDone($taskUUID);
|
|
|
|
$path = $this->downloadTo("generations/videos/{$taskUUID}", $result['videoURL']);
|
|
|
|
$generation->update([
|
|
'status' => 'completed',
|
|
'runware_task_uuid' => $taskUUID,
|
|
'output_path' => $path,
|
|
...$this->costFields($result['cost'] ?? null),
|
|
]);
|
|
}
|
|
|
|
protected function pollUntilDone(string $taskUUID): array
|
|
{
|
|
$delaySeconds = 2;
|
|
$elapsed = 0;
|
|
$maxSeconds = 600;
|
|
|
|
while ($elapsed < $maxSeconds) {
|
|
$result = $this->client->pollTask($taskUUID);
|
|
$status = $result['status'] ?? null;
|
|
|
|
if ($status === 'success') {
|
|
return $result;
|
|
}
|
|
|
|
if ($status === 'error') {
|
|
throw new RuntimeException('Runware video task failed: '.json_encode($result));
|
|
}
|
|
|
|
sleep($delaySeconds);
|
|
$elapsed += $delaySeconds;
|
|
$delaySeconds = min(10, $delaySeconds + 2);
|
|
}
|
|
|
|
throw new RuntimeException("Runware video task {$taskUUID} timed out after {$maxSeconds}s");
|
|
}
|
|
|
|
protected function downloadTo(string $relativePath, string $url): string
|
|
{
|
|
$response = Http::timeout(120)->get($url);
|
|
|
|
if ($response->failed()) {
|
|
throw new RuntimeException("Failed to download Runware output from {$url}");
|
|
}
|
|
|
|
$extension = pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION) ?: 'bin';
|
|
$path = "{$relativePath}.{$extension}";
|
|
|
|
Storage::disk('public')->put($path, $response->body());
|
|
|
|
return $path;
|
|
}
|
|
|
|
protected function costFields(?float $costUsd, bool $estimated = false): array
|
|
{
|
|
if ($costUsd === null) {
|
|
return ['cost_usd' => null, 'cost_eur' => null, 'cost_is_estimated' => false];
|
|
}
|
|
|
|
return [
|
|
'cost_usd' => $costUsd,
|
|
'cost_eur' => $costUsd * $this->rates->usdToEur(),
|
|
'cost_is_estimated' => $estimated,
|
|
];
|
|
}
|
|
}
|