Initial commit: Runware-powered content generator foundation

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.
This commit is contained in:
2026-09-23 15:59:05 +03:00
commit 79320950e7
78 changed files with 14452 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace App\Services\Runware;
use App\Exceptions\BudgetExceededException;
use App\Models\Generation;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* Enforces the hard monthly EUR spend cap on Runware usage. Every generation
* must go through ensureCanSpend() before the API is called, and the actual
* cost must be reconciled afterward via recordActualUsd() so the running
* total reflects reality (Runware only bills successful generations).
*/
class BudgetGuard
{
protected const LOCK_KEY = 'runware:budget-guard-lock';
protected const LOCK_TIMEOUT_SECONDS = 10;
public function __construct(protected CurrencyRateService $rates) {}
public function capEur(): float
{
return (float) config('runware.monthly_cap_eur');
}
public function spentEurThisMonth(): float
{
return (float) Generation::query()
->where('created_at', '>=', now()->startOfMonth())
->whereNotNull('cost_eur')
->sum('cost_eur');
}
public function remainingEur(): float
{
return max(0.0, $this->capEur() - $this->spentEurThisMonth());
}
public function warnThresholdReached(): bool
{
$cap = $this->capEur();
if ($cap <= 0) {
return false;
}
return ($this->spentEurThisMonth() / $cap) >= (float) config('runware.warn_threshold');
}
/**
* Throws BudgetExceededException if spending $estimatedUsd would push
* this month's total (plus a safety margin) past the cap. Guards the
* check with an atomic lock so concurrent requests can't both slip
* through right at the boundary.
*/
public function ensureCanSpend(float $estimatedUsd): void
{
Cache::lock(self::LOCK_KEY, self::LOCK_TIMEOUT_SECONDS)->block(5, function () use ($estimatedUsd) {
$rate = $this->rates->usdToEur();
$estimatedEur = $estimatedUsd * $rate;
$spent = $this->spentEurThisMonth();
$cap = $this->capEur();
$margin = $cap * (float) config('runware.safety_margin');
if ($spent + $estimatedEur + $margin > $cap) {
throw new BudgetExceededException($spent, $cap, $estimatedEur);
}
});
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Services\Runware;
use App\Models\RunwareModel;
/**
* Pre-flight cost estimation used by BudgetGuard before a Runware call is
* made. Deliberately conservative — the real cost is always reconciled
* afterward from Runware's response `cost` field (or, for text, `usage.cost`).
*/
class CostEstimator
{
protected const DEFAULT_VIDEO_DURATION_SECONDS = 5;
protected const DEFAULT_TEXT_TOKEN_BUDGET = 2048;
public function estimateUsd(RunwareModel $model, array $params): float
{
return match ($model->unit) {
'per_image' => (float) $model->price_usd * (int) ($params['numberResults'] ?? 1),
'per_second' => (float) $model->price_usd * (int) ($params['duration'] ?? self::DEFAULT_VIDEO_DURATION_SECONDS),
'per_million_tokens' => (float) $model->price_usd
* (((int) ($params['max_completion_tokens'] ?? self::DEFAULT_TEXT_TOKEN_BUDGET)) / 1_000_000),
};
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Services\Runware;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Keeps a live-ish USD->EUR rate cached for BudgetGuard to convert Runware's
* USD costs against the EUR monthly cap. Refreshed daily by the scheduler
* (see routes/console.php); falls back to a static configured rate if no
* live fetch has ever succeeded, so the cap never stops enforcing.
*/
class CurrencyRateService
{
protected const CACHE_KEY = 'runware:usd_eur_rate';
// Refresh runs daily; keep the cached value alive well past that so a
// single failed fetch doesn't drop back to the fallback rate.
protected const CACHE_TTL_SECONDS = 60 * 60 * 30;
public function usdToEur(): float
{
return Cache::get(self::CACHE_KEY) ?? (float) config('runware.usd_eur_fallback_rate');
}
public function refresh(): ?float
{
try {
$response = Http::timeout(10)->get('https://api.frankfurter.app/latest', [
'from' => 'USD',
'to' => 'EUR',
]);
$rate = $response->json('rates.EUR');
if (! $response->successful() || ! is_numeric($rate)) {
throw new \RuntimeException('Unexpected FX response: '.$response->body());
}
Cache::put(self::CACHE_KEY, (float) $rate, self::CACHE_TTL_SECONDS);
return (float) $rate;
} catch (\Throwable $e) {
Log::warning('Failed to refresh USD/EUR rate, keeping previous cached/fallback value.', [
'exception' => $e->getMessage(),
]);
return null;
}
}
}
+251
View File
@@ -0,0 +1,251 @@
<?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,
];
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Services\Runware;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Thin wrapper around Runware's HTTP API. Image and video generation use the
* batched task-array endpoint (POST /v1, body is a JSON array of tasks);
* text/LLM generation uses Runware's separate OpenAI-compatible endpoint
* (POST /v1/chat/completions). See https://runware.ai/docs/platform/introduction
* and https://runware.ai/docs/platform/openai.
*/
class RunwareClient
{
public function __construct(
protected string $apiKey,
protected string $baseUrl,
) {}
/**
* @param array<int, array<string, mixed>> $tasks
* @return array<int, array<string, mixed>>
*/
public function runTasks(array $tasks): array
{
$response = Http::withToken($this->apiKey)
->timeout(120)
->post($this->baseUrl, $tasks);
if ($response->failed()) {
throw new RuntimeException('Runware API request failed: '.$response->body());
}
$data = $response->json('data');
if (! is_array($data)) {
throw new RuntimeException('Unexpected Runware response shape: '.$response->body());
}
foreach ($data as $result) {
if (isset($result['error']) || ($result['status'] ?? null) === 'error') {
throw new RuntimeException('Runware task failed: '.json_encode($result));
}
}
return $data;
}
public function pollTask(string $taskUUID): array
{
$results = $this->runTasks([[
'taskType' => 'getResponse',
'taskUUID' => $taskUUID,
]]);
return $results[0] ?? throw new RuntimeException("No poll result for task {$taskUUID}");
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function chatCompletion(array $payload): array
{
$response = Http::withToken($this->apiKey)
->timeout(120)
->post(rtrim($this->baseUrl, '/').'/chat/completions', $payload);
if ($response->failed()) {
throw new RuntimeException('Runware chat completion failed: '.$response->body());
}
return $response->json();
}
}