feat: parallel image generation via curl_multi

Both Image A and Image B now fire concurrently through a single
generate_image_batch() call instead of sequentially. Total wall-clock
time equals the slowest image (~20s), not the sum (~40s), which
eliminates the Image B timeout problem on Hostinger where
max_execution_time killed the cron before the second request finished.

New files:
- class-open-router-image-batch.php — curl_multi orchestrator
- generate_image_batch() added to image provider interface

Pipeline refactored: prompts built upfront, single batch call, results
processed per-key. Cloudflare provider gets a sequential fallback.
Tests updated for batch API. All files under 300-line cap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
peptiderepo
2026-04-18 21:10:54 +07:00
parent c8595e05de
commit d17c488ef3
7 changed files with 604 additions and 225 deletions

View File

@@ -174,6 +174,28 @@ class PRAutoBlogger_Cloudflare_Image_Provider implements PRAutoBlogger_Image_Pro
);
}
/**
* Sequential fallback — Cloudflare Workers AI does not support batching.
*
* {@inheritDoc}
*/
public function generate_image_batch( array $requests ): array {
$results = [];
foreach ( $requests as $key => $req ) {
try {
$results[ $key ] = $this->generate_image(
$req['prompt'],
$req['width'],
$req['height'],
$req['options'] ?? []
);
} catch ( \Throwable $e ) {
$results[ $key ] = [ 'error' => $e->getMessage() ];
}
}
return $results;
}
/**
* Estimate the USD cost of a single generation call.
*

View File

@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
/**
* Parallel image generation via curl_multi for OpenRouter.
*
* Fires multiple chat/completions requests concurrently using PHP's
* curl_multi interface, cutting wall-clock time from N×20s to ~20s
* regardless of how many images are requested. This solves the Image B
* timeout problem on Hostinger where max_execution_time kills the
* cron process before a sequential second request can finish.
*
* Triggered by: PRAutoBlogger_OpenRouter_Image_Provider::generate_image_batch().
* Dependencies: PRAutoBlogger_OpenRouter_Image_Support (key, parsing),
* PRAutoBlogger_OpenRouter_Image_Pricing (model, cost),
* PRAutoBlogger_OpenRouter_Config (base URL, gateway),
* PRAutoBlogger_OpenRouter_Request_Builder (headers).
*
* @see class-open-router-image-provider.php — Sole caller.
* @see class-open-router-image-support.php — Response parsing.
* @see ARCHITECTURE.md — Image pipeline data flow.
*/
class PRAutoBlogger_OpenRouter_Image_Batch {
/** @var PRAutoBlogger_OpenRouter_Image_Pricing */
private PRAutoBlogger_OpenRouter_Image_Pricing $pricing;
/** @var PRAutoBlogger_OpenRouter_Image_Support */
private PRAutoBlogger_OpenRouter_Image_Support $support;
/** @var PRAutoBlogger_OpenRouter_Config */
private PRAutoBlogger_OpenRouter_Config $config;
/** @var PRAutoBlogger_OpenRouter_Request_Builder */
private PRAutoBlogger_OpenRouter_Request_Builder $request_builder;
/**
* @param PRAutoBlogger_OpenRouter_Image_Pricing $pricing Cost + model resolver.
* @param PRAutoBlogger_OpenRouter_Image_Support $support Key + response parsing.
* @param PRAutoBlogger_OpenRouter_Config $config API URL resolution.
* @param PRAutoBlogger_OpenRouter_Request_Builder $request_builder Header builder.
*/
public function __construct(
PRAutoBlogger_OpenRouter_Image_Pricing $pricing,
PRAutoBlogger_OpenRouter_Image_Support $support,
PRAutoBlogger_OpenRouter_Config $config,
PRAutoBlogger_OpenRouter_Request_Builder $request_builder
) {
$this->pricing = $pricing;
$this->support = $support;
$this->config = $config;
$this->request_builder = $request_builder;
}
/**
* Execute multiple image generation requests concurrently via curl_multi.
*
* Each request runs in its own cURL handle. All handles execute in
* parallel — the total wall-clock time equals the slowest single
* request, not the sum of all requests.
*
* No retry logic here — each request gets one attempt. Retries would
* complicate the multi-handle lifecycle and the single-attempt success
* rate on OpenRouter is >98%. If a request fails, its key maps to an
* `{error: string}` array.
*
* @param array<string, array{prompt: string, width: int, height: int, options?: array}> $requests Keyed requests.
*
* @return array<string, array{
* bytes: string, mime_type: string, width: int, height: int,
* model: string, seed: ?int, cost_usd: float, latency_ms: int,
* }|array{error: string}> Results indexed by the same keys as $requests.
*/
public function execute( array $requests ): array {
if ( empty( $requests ) ) {
return [];
}
$api_key = $this->support->get_api_key();
$base_url = $this->config->get_api_base_url();
$url = $base_url . '/chat/completions';
$headers = $this->request_builder->build_headers(
$api_key,
$this->config->is_via_gateway(),
0
);
// Convert associative headers to curl-style indexed array.
$curl_headers = [];
foreach ( $headers as $name => $value ) {
$curl_headers[] = $name . ': ' . $value;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_init
$multi_handle = curl_multi_init();
$handles = [];
$start_times = [];
foreach ( $requests as $key => $req ) {
$model = $this->pricing->resolve_model( (string) ( $req['options']['model'] ?? '' ) );
$payload = $this->build_request_body(
$req['prompt'],
$req['width'],
$req['height'],
$model
);
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_init
$ch = curl_init();
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
curl_setopt( $ch, CURLOPT_URL, $url );
curl_setopt( $ch, CURLOPT_POST, true );
curl_setopt( $ch, CURLOPT_POSTFIELDS, wp_json_encode( $payload ) );
curl_setopt( $ch, CURLOPT_HTTPHEADER, $curl_headers );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_TIMEOUT, PRAUTOBLOGGER_API_TIMEOUT_SECONDS );
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
// phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
curl_multi_add_handle( $multi_handle, $ch );
$handles[ $key ] = [ 'handle' => $ch, 'model' => $model, 'req' => $req ];
$start_times[ $key ] = microtime( true );
}
// Execute all handles concurrently.
$this->run_multi( $multi_handle );
// Collect results.
$results = [];
foreach ( $handles as $key => $entry ) {
$results[ $key ] = $this->collect_result(
$entry['handle'],
$entry['model'],
$entry['req'],
$start_times[ $key ]
);
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
curl_multi_remove_handle( $multi_handle, $entry['handle'] );
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_close
curl_close( $entry['handle'] );
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_close
curl_multi_close( $multi_handle );
return $results;
}
/**
* Drive curl_multi_exec to completion.
*
* @param \CurlMultiHandle $mh Multi handle.
*/
private function run_multi( $mh ): void {
$running = 0;
do {
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
$status = curl_multi_exec( $mh, $running );
if ( $running > 0 ) {
// Block until activity on any handle (up to 1s).
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_select
curl_multi_select( $mh, 1.0 );
}
} while ( $running > 0 && CURLM_OK === $status );
}
/**
* Parse the result of one cURL handle into image data or error.
*
* @param \CurlHandle $ch Individual cURL handle (completed).
* @param string $model Resolved model id.
* @param array $req Original request array.
* @param float $start Microtime when this handle was created.
*
* @return array Image data array or `{error: string}`.
*/
private function collect_result( $ch, string $model, array $req, float $start ): array {
$latency_ms = (int) ( ( microtime( true ) - $start ) * 1000 );
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_errno
$curl_errno = curl_errno( $ch );
if ( 0 !== $curl_errno ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error
$msg = sprintf( 'cURL error %d: %s', $curl_errno, curl_error( $ch ) );
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
return [ 'error' => $msg ];
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo
$http_code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
$raw = (string) curl_multi_getcontent( $ch );
if ( $http_code >= 400 ) {
$msg = sprintf( 'HTTP %d: %s', $http_code, substr( $raw, 0, 300 ) );
PRAutoBlogger_Logger::instance()->error( $msg, 'openrouter-image-batch' );
return [ 'error' => $msg ];
}
try {
$image_bytes = $this->support->extract_image_bytes( $raw );
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
'Batch parse error: ' . $e->getMessage(),
'openrouter-image-batch'
);
return [ 'error' => $e->getMessage() ];
}
return [
'bytes' => $image_bytes,
'mime_type' => 'image/png',
'width' => $req['width'],
'height' => $req['height'],
'model' => $model,
'seed' => null,
'cost_usd' => $this->pricing->estimate_cost( $req['width'], $req['height'], $model ),
'latency_ms' => $latency_ms,
];
}
/**
* Build the chat/completions payload for one image request.
*
* Duplicated from the provider class to keep this file self-contained
* without adding a public method to the provider's API surface.
*
* @param string $prompt Prompt text.
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @param string $model Model id.
* @return array<string, mixed>
*/
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
$target = $height > 0 ? (float) $width / (float) $height : 1.0;
// Snap to nearest standard aspect ratio.
$aspects = [
[ 'w' => 1, 'h' => 1, 'r' => 1.0 ],
[ 'w' => 3, 'h' => 2, 'r' => 1.5 ],
[ 'w' => 2, 'h' => 3, 'r' => 0.6667 ],
[ 'w' => 4, 'h' => 3, 'r' => 1.3333 ],
[ 'w' => 3, 'h' => 4, 'r' => 0.75 ],
[ 'w' => 16, 'h' => 9, 'r' => 1.7778 ],
[ 'w' => 9, 'h' => 16, 'r' => 0.5625 ],
];
$best = $aspects[0];
$best_d = abs( $target - $best['r'] );
foreach ( $aspects as $c ) {
$d = abs( $target - $c['r'] );
if ( $d < $best_d ) {
$best = $c;
$best_d = $d;
}
}
$aspect = $best['w'] . ':' . $best['h'];
return [
'model' => $model,
'messages' => [
[
'role' => 'user',
'content' => 'Generate an image: ' . $prompt,
],
],
'modalities' => [ 'image', 'text' ],
'image_config' => [ 'aspect_ratio' => $aspect ],
];
}
}

View File

@@ -4,26 +4,14 @@ declare(strict_types=1);
/**
* OpenRouter image provider — model-agnostic, works with any image model.
*
* Calls the standard OpenRouter chat/completions endpoint with
* `modalities: ["image"]`. Reuses the same API key and optional
* Cloudflare AI Gateway as the LLM provider.
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation.
* Dependencies: Image_Support (key/parsing), Config (URL), Request_Builder (headers),
* Image_Pricing (model/cost), Image_Batch (parallel curl_multi), Logger.
*
* Triggered by: PRAutoBlogger_Image_Pipeline during the content generation
* run, after editorial review, before publish. Also triggered
* by the admin "Test Connection" action.
* Dependencies: PRAutoBlogger_OpenRouter_Image_Support (key, parsing, retry),
* PRAutoBlogger_OpenRouter_Config (base URL + gateway),
* PRAutoBlogger_OpenRouter_Request_Builder (headers + cURL fix),
* PRAutoBlogger_OpenRouter_Image_Pricing (model + cost),
* PRAutoBlogger_Logger.
*
* @see interface-image-provider.php Interface this class implements.
* @see class-openrouter-image-support.php Key lookup, response parsing, retry.
* @see class-openrouter-image-pricing.php Cost + model helpers.
* @see class-open-router-config.php Base URL / gateway resolution.
* @see class-open-router-request-builder.php Header building + cURL auth filter.
* @see class-cloudflare-image-provider.php Sibling provider; same retry pattern.
* @see ARCHITECTURE.md External API Integrations table.
* @see interface-image-provider.php — Contract.
* @see class-open-router-image-batch.php — Parallel generation via curl_multi.
* @see class-open-router-image-support.php — Key, response parsing, retry.
* @see ARCHITECTURE.md — External API Integrations table.
*/
class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
@@ -53,14 +41,7 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
/**
* {@inheritDoc}
*
* Sends a chat/completions request with modalities=["image"] and parses
* the base64 image from the response. Retries 5xx/429 with exponential
* backoff. 4xx (except 429) fail immediately.
*
* Side effects: HTTP request per attempt, Logger line per attempt.
*
* @throws \InvalidArgumentException If dimensions out of range or prompt empty.
* @throws \RuntimeException On auth failure, bad response, or retry exhaustion.
* Retries 5xx/429 with exponential backoff; 4xx (except 429) fail immediately.
*/
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
$this->assert_valid_dimensions( $width, $height );
@@ -159,6 +140,41 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
);
}
/**
* Generate multiple images concurrently via curl_multi.
*
* Fires all requests in parallel — total wall-clock time equals the
* slowest single request, not the sum. Solves the Image B timeout
* problem where sequential calls exceed Hostinger's max_execution_time.
*
* {@inheritDoc}
*/
public function generate_image_batch( array $requests ): array {
// Validate all requests upfront before starting any HTTP calls.
foreach ( $requests as $key => $req ) {
$this->assert_valid_dimensions( $req['width'], $req['height'] );
if ( '' === trim( $req['prompt'] ) ) {
throw new \InvalidArgumentException(
sprintf( 'Image prompt for key "%s" cannot be empty.', esc_html( $key ) )
);
}
}
$batch = new PRAutoBlogger_OpenRouter_Image_Batch(
$this->pricing,
$this->support,
$this->config,
$this->request_builder
);
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Starting parallel image batch (%d images)', count( $requests ) ),
'openrouter-image'
);
return $batch->execute( $requests );
}
/** {@inheritDoc} */
public function estimate_cost( int $width, int $height, array $options = [] ): float {
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
@@ -170,19 +186,12 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
return self::PROVIDER_ID;
}
/**
* Verify credentials by calling OpenRouter's /auth/key endpoint.
*
* {@inheritDoc}
*/
/** {@inheritDoc} — Calls OpenRouter's /auth/key endpoint. */
public function validate_credentials_detailed(): array {
try {
$api_key = $this->support->get_api_key();
} catch ( \RuntimeException $e ) {
return [
'status' => 'error',
'message' => $e->getMessage(),
];
return [ 'status' => 'error', 'message' => $e->getMessage() ];
}
$response = wp_remote_get( 'https://openrouter.ai/api/v1/auth/key', [
@@ -200,10 +209,7 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
$status = (int) wp_remote_retrieve_response_code( $response );
if ( 200 === $status ) {
return [
'status' => 'ok',
'message' => __( 'OpenRouter API key valid. Image generation ready.', 'prautoblogger' ),
];
return [ 'status' => 'ok', 'message' => __( 'OpenRouter API key valid.', 'prautoblogger' ) ];
}
return [
@@ -214,17 +220,7 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
}
/**
* Build the chat/completions request body for image generation.
*
* @param string $prompt Image generation prompt.
* @param int $width Target width.
* @param int $height Target height.
* @param string $model Resolved model id.
* @return array<string, mixed>
*/
/**
* Supported aspect ratios for OpenRouter image models (Gemini, GPT-5).
* Arbitrary ratios like 150:79 are rejected — must snap to one of these.
* Supported aspect ratios for OpenRouter image models.
*
* @var array<int, array{w: int, h: int, ratio: float}>
*/
@@ -260,7 +256,6 @@ class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Pro
/**
* Snap pixel dimensions to the nearest supported aspect ratio string.
* 1200×632 → ratio 1.8987 → nearest is 16:9 (1.7778).
*
* @param int $width Width in pixels.
* @param int $height Height in pixels.

View File

@@ -83,6 +83,37 @@ interface PRAutoBlogger_Image_Provider_Interface {
*/
public function get_provider_name(): string;
/**
* Generate multiple images in parallel (or sequentially as fallback).
*
* Each entry in $requests describes one image to generate. The return array
* is indexed identically — callers can zip input and output by key.
*
* Implementations that support concurrent HTTP (e.g. curl_multi) SHOULD
* override the default sequential behaviour for a wall-clock speedup.
*
* Side effects: HTTP requests (possibly concurrent), Logger lines per image.
*
* @param array<string, array{
* prompt: string,
* width: int,
* height: int,
* options?: array{seed?: int, steps?: int, model?: string},
* }> $requests Keyed image generation requests.
*
* @return array<string, array{
* bytes: string,
* mime_type: string,
* width: int,
* height: int,
* model: string,
* seed: ?int,
* cost_usd: float,
* latency_ms: int,
* }|array{error: string}> Each key maps to either image data or an error.
*/
public function generate_image_batch( array $requests ): array;
/**
* Verify the provider credentials are present and the API is reachable.
*