feat: add OpenRouter image provider for higher-quality image generation

Replaces Cloudflare Workers AI (FLUX.1 schnell) as the default image
provider with OpenRouter, which offers FLUX.2 Pro, Seedream 3.0,
Gemini Flash Image, and GPT Image 1 at comparable or better pricing.

- New provider class (3-file split: provider, pricing, support) reuses
  the existing OpenRouter API key and Cloudflare AI Gateway
- Admin settings: new Image Provider dropdown (openrouter/cloudflare),
  model picker shows all available image models from both providers
- Image pipeline auto-selects the configured provider at construction
- Cost logging now uses actual model name instead of hardcoded flux-1-schnell
- Default model changed to black-forest-labs/flux-2-pro ($0.03/image)
- Unit tests for pricing resolution and provider response parsing

Cost impact: FLUX.2 Pro at $0.03/image vs schnell at $0.0007/image —
~43x more expensive per image, but dramatically better quality. At 2
images/article × 1 article/day = ~$1.80/month. Seedream 3.0 ($0.003)
available as a budget alternative.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
peptiderepo
2026-04-18 09:19:13 +07:00
parent b5e91ba3a6
commit 0a68741945
11 changed files with 852 additions and 44 deletions

View File

@@ -22,10 +22,10 @@
* @return {jQuery.Deferred} Resolves with model array.
*/
function fetchModels(capability) {
// Cloudflare image models are embedded in the page config — no AJAX needed.
if (capability === 'cloudflare_image') {
var cfModels = config.cloudflareModels || [];
return $.Deferred().resolve(cfModels);
// Image models are embedded in the page config — no AJAX needed.
if (capability === 'image_generation') {
var imgModels = config.imageModels || [];
return $.Deferred().resolve(imgModels);
}
if (cachedModels) {
@@ -160,8 +160,8 @@
var capability = $trigger.data('capability') || '';
// Build overlay + popup structure.
var isCloudflare = capability === 'cloudflare_image';
var priceLabel = isCloudflare ? 'Cost per generated image' : 'Prices per 1M tokens (in / out)';
var isImageCap = capability === 'image_generation';
var priceLabel = isImageCap ? 'Cost per generated image' : 'Prices per 1M tokens (in / out)';
var $overlay = $('<div class="ab-mp-overlay"></div>');
var $popup = $(

View File

@@ -80,22 +80,8 @@ class PRAutoBlogger_Admin_Page {
'generateText' => __( 'Generate Now', 'prautoblogger' ),
'testingText' => __( 'Testing...', 'prautoblogger' ),
'testText' => __( 'Test Connections', 'prautoblogger' ),
// Static Cloudflare image models for the picker (no registry API for these).
// FLUX.1 [dev] removed from Cloudflare Workers AI in April 2026.
'cloudflareModels' => [
[
'id' => 'flux-1-schnell',
'name' => 'FLUX.1 [schnell]',
'provider' => 'cloudflare',
'context_length' => 0,
'input_price_per_m' => 0,
'output_price_per_m' => 0,
'cost_per_image' => 0.0007,
'capabilities' => [ 'cloudflare_image' ],
'deprecated' => false,
'description' => __( 'Fast + cheap (4 steps). Default choice.', 'prautoblogger' ),
],
],
// Image models for the picker — defined in the settings class to keep this file short.
'imageModels' => PRAutoBlogger_Settings_Fields_Extended::get_image_models(),
] );
}

View File

@@ -14,6 +14,73 @@ declare(strict_types=1);
*/
class PRAutoBlogger_Settings_Fields_Extended {
/**
* Static image model list for the admin model picker.
* No registry API for these — they're hardcoded here and updated manually.
*
* @return array<int, array<string, mixed>>
*/
public static function get_image_models(): array {
return [
[
'id' => 'black-forest-labs/flux-2-pro',
'name' => 'FLUX.2 Pro',
'provider' => 'openrouter',
'cost_per_image' => 0.03,
'capabilities' => [ 'image_generation' ],
'description' => __( 'High quality, fast. Recommended.', 'prautoblogger' ),
],
[
'id' => 'black-forest-labs/flux-1.1-pro',
'name' => 'FLUX 1.1 Pro',
'provider' => 'openrouter',
'cost_per_image' => 0.04,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Previous gen Pro. Slightly pricier.', 'prautoblogger' ),
],
[
'id' => 'black-forest-labs/flux-1.1-pro-ultra',
'name' => 'FLUX 1.1 Pro Ultra',
'provider' => 'openrouter',
'cost_per_image' => 0.06,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Highest quality FLUX. Premium.', 'prautoblogger' ),
],
[
'id' => 'bytedance/seedream-3.0',
'name' => 'Seedream 3.0',
'provider' => 'openrouter',
'cost_per_image' => 0.003,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Budget. Good quality, very low cost.', 'prautoblogger' ),
],
[
'id' => 'google/gemini-2.5-flash-preview:image',
'name' => 'Gemini 2.5 Flash Image',
'provider' => 'openrouter',
'cost_per_image' => 0.01,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Google multimodal. Text-in-image.', 'prautoblogger' ),
],
[
'id' => 'openai/gpt-image-1',
'name' => 'GPT Image 1',
'provider' => 'openrouter',
'cost_per_image' => 0.04,
'capabilities' => [ 'image_generation' ],
'description' => __( 'OpenAI. Photorealistic style.', 'prautoblogger' ),
],
[
'id' => 'flux-1-schnell',
'name' => 'FLUX.1 schnell (Cloudflare)',
'provider' => 'cloudflare',
'cost_per_image' => 0.0007,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Cheapest. Low quality, 4-step.', 'prautoblogger' ),
],
];
}
/**
* Get schedule, publishing, analytics, and image settings fields.
*
@@ -113,14 +180,36 @@ class PRAutoBlogger_Settings_Fields_Extended {
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '0',
'description' => __( 'When enabled, generate two images (A/B) for each published article. Requires Cloudflare credentials.', 'prautoblogger' ),
'description' => __( 'When enabled, generate two images (A/B) for each published article.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_provider',
'label' => __( 'Image Provider', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_images',
'default' => 'openrouter',
'options' => [
'openrouter' => __( 'OpenRouter (FLUX.2 Pro, Seedream, GPT-Image)', 'prautoblogger' ),
'cloudflare' => __( 'Cloudflare Workers AI (FLUX.1 schnell)', 'prautoblogger' ),
],
'description' => __( 'OpenRouter reuses your existing API key and offers higher-quality models. Cloudflare is cheaper but lower quality.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_model',
'label' => __( 'Image Model', 'prautoblogger' ),
'type' => 'model_select',
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
'capability' => 'image_generation',
'description' => __( 'Pick a model from your selected provider. FLUX.2 Pro recommended for quality.', 'prautoblogger' ),
'badge' => __( 'Quality', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_cloudflare_ai_token',
'label' => __( 'Cloudflare API Token', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_images',
'description' => __( 'Workers AI token with Read + Edit scope. Create at dash.cloudflare.com → AI → API Tokens.', 'prautoblogger' ),
'description' => __( 'Only needed if using Cloudflare provider. Workers AI token with Read + Edit scope.', 'prautoblogger' ),
'icon' => '🔑',
],
[
@@ -129,17 +218,7 @@ class PRAutoBlogger_Settings_Fields_Extended {
'type' => 'text',
'section' => 'prautoblogger_images',
'default' => '',
'description' => __( 'The Account ID shown on your Cloudflare dashboard sidebar.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_model',
'label' => __( 'Image Model', 'prautoblogger' ),
'type' => 'model_select',
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
'capability' => 'cloudflare_image',
'description' => __( 'Schnell is the normal choice. Switch to [dev] for higher quality.', 'prautoblogger' ),
'badge' => __( 'Low cost', 'prautoblogger' ),
'description' => __( 'Only needed if using Cloudflare provider.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_style_suffix',

View File

@@ -63,7 +63,7 @@ class PRAutoBlogger_Settings_Fields {
'prautoblogger_images' => [
'title' => __( 'Images', 'prautoblogger' ),
'icon' => 'dashicons-format-image',
'description' => __( 'Cloudflare Workers AI credentials and image style controls.', 'prautoblogger' ),
'description' => __( 'Image provider, model, and style controls. OpenRouter reuses your existing API key.', 'prautoblogger' ),
],
];
}

View File

@@ -63,19 +63,36 @@ class PRAutoBlogger_Image_Pipeline {
/**
* Construct with dependencies.
*
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider (defaults to Cloudflare).
* When no provider is injected, the constructor reads the
* `prautoblogger_image_provider` setting and instantiates the
* matching concrete class ('openrouter' or 'cloudflare').
*
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider override.
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
*/
public function __construct(
?PRAutoBlogger_Image_Provider_Interface $provider = null,
?PRAutoBlogger_Cost_Tracker $cost_tracker = null
) {
$this->provider = $provider ?? new PRAutoBlogger_Cloudflare_Image_Provider();
$this->provider = $provider ?? self::create_default_provider();
$this->prompt_builder = new PRAutoBlogger_Image_Prompt_Builder();
$this->sideloader = new PRAutoBlogger_Image_Media_Sideloader();
$this->cost_tracker = $cost_tracker ?? new PRAutoBlogger_Cost_Tracker();
}
/**
* Instantiate the image provider based on the admin setting.
*
* @return PRAutoBlogger_Image_Provider_Interface
*/
private static function create_default_provider(): PRAutoBlogger_Image_Provider_Interface {
$provider_id = (string) get_option( 'prautoblogger_image_provider', PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER );
if ( 'openrouter' === $provider_id ) {
return new PRAutoBlogger_OpenRouter_Image_Provider();
}
return new PRAutoBlogger_Cloudflare_Image_Provider();
}
/**
* Generate and attach images to a published post.
*
@@ -190,7 +207,7 @@ class PRAutoBlogger_Image_Pipeline {
// Log the cost.
$this->cost_tracker->log_image_generation(
$image_data['cost_usd'],
'flux-1-schnell',
$image_data['model'] ?? 'unknown',
$post_id,
'image_a'
);
@@ -256,7 +273,7 @@ class PRAutoBlogger_Image_Pipeline {
// Log the cost.
$this->cost_tracker->log_image_generation(
$image_data['cost_usd'],
'flux-1-schnell',
$image_data['model'] ?? 'unknown',
$post_id,
'image_b'
);

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/**
* Pricing + model-id resolution helpers for the OpenRouter image provider.
*
* Mirrors the Cloudflare pricing helper pattern. Each model's cost per image
* is a flat rate sourced from OpenRouter's pricing page (April 2026).
*
* Triggered by: PRAutoBlogger_OpenRouter_Image_Provider (both generate_image()
* and estimate_cost() delegate here).
* Dependencies: None — pure calculation + option reads.
*
* @see class-openrouter-image-provider.php — Caller.
* @see class-cloudflare-image-pricing.php — Sibling pattern for Cloudflare.
*/
class PRAutoBlogger_OpenRouter_Image_Pricing {
/**
* Flat per-image cost (USD) for each supported model, April 2026.
* Update here when OpenRouter reprices or new models launch.
*
* @var array<string, float>
*/
private const COST_PER_IMAGE = [
'black-forest-labs/flux-2-pro' => 0.03,
'black-forest-labs/flux-1.1-pro' => 0.04,
'black-forest-labs/flux-1.1-pro-ultra' => 0.06,
'bytedance/seedream-3.0' => 0.003,
'google/gemini-2.5-flash-preview:image' => 0.01,
'openai/gpt-image-1' => 0.04,
];
/** @var string Default model when none is configured. */
public const DEFAULT_MODEL = 'black-forest-labs/flux-2-pro';
/**
* Resolve the model identifier from a hint, falling back to site option,
* then to the hardcoded default.
*
* @param string $hint Caller-supplied model identifier (may be empty).
* @return string OpenRouter model id.
*/
public function resolve_model( string $hint ): string {
$hint = trim( $hint );
if ( '' === $hint ) {
$hint = (string) get_option( 'prautoblogger_image_model', self::DEFAULT_MODEL );
}
// Validate the model exists in our cost table; fall back to default.
if ( ! isset( self::COST_PER_IMAGE[ $hint ] ) ) {
return self::DEFAULT_MODEL;
}
return $hint;
}
/**
* Estimate USD cost for a single image at the given model.
*
* OpenRouter image models use flat per-image pricing, not per-megapixel.
* Width/height are accepted for interface compatibility but don't affect cost.
*
* @param int $width Width in pixels (unused for flat pricing).
* @param int $height Height in pixels (unused for flat pricing).
* @param string $model Resolved OpenRouter model id.
* @return float USD cost rounded to 6 decimals.
*/
public function estimate_cost( int $width, int $height, string $model ): float {
return round( self::COST_PER_IMAGE[ $model ] ?? self::COST_PER_IMAGE[ self::DEFAULT_MODEL ], 6 );
}
/**
* Get the full model cost table for admin display.
*
* @return array<string, float> Model id => cost per image in USD.
*/
public static function get_model_costs(): array {
return self::COST_PER_IMAGE;
}
}

View File

@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
/**
* OpenRouter image provider — FLUX.2, Seedream, GPT-Image, Gemini Flash.
*
* 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 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.
*/
class PRAutoBlogger_OpenRouter_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
private const PROVIDER_ID = 'openrouter';
private const MIN_DIMENSION_PX = 64;
private const MAX_DIMENSION_PX = 2048;
/** @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;
public function __construct() {
$this->pricing = new PRAutoBlogger_OpenRouter_Image_Pricing();
$this->support = new PRAutoBlogger_OpenRouter_Image_Support();
$this->config = new PRAutoBlogger_OpenRouter_Config();
$this->request_builder = new PRAutoBlogger_OpenRouter_Request_Builder();
}
/**
* {@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.
*/
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
$this->assert_valid_dimensions( $width, $height );
$prompt = trim( $prompt );
if ( '' === $prompt ) {
throw new \InvalidArgumentException(
esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' )
);
}
$api_key = $this->support->get_api_key();
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
$body = $this->build_request_body( $prompt, $width, $height, $model );
$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 // No caching for image gen — every call should be unique.
);
$curl_filter = $this->request_builder->register_curl_auth_filter(
$headers,
wp_parse_url( $base_url, PHP_URL_HOST ) ?: ''
);
$started_at = microtime( true );
$last_error = '';
try {
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
$response = wp_remote_post( $url, [
'timeout' => PRAUTOBLOGGER_API_TIMEOUT_SECONDS,
'headers' => $headers,
'body' => wp_json_encode( $body ),
] );
if ( is_wp_error( $response ) ) {
$last_error = $response->get_error_message();
$this->support->log_retry( $attempt, 'network', $last_error );
$this->support->backoff( $attempt );
continue;
}
$status = (int) wp_remote_retrieve_response_code( $response );
$raw = (string) wp_remote_retrieve_body( $response );
if ( 429 === $status || $status >= 500 ) {
$last_error = sprintf( 'HTTP %d: %s', $status, substr( $raw, 0, 300 ) );
$this->support->log_retry( $attempt, (string) $status, $last_error );
if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) {
$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
$this->support->backoff( $attempt, $retry_after );
}
continue;
}
if ( $status >= 400 ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'OpenRouter image gen HTTP %d: %s', $status, substr( $raw, 0, 300 ) ),
'openrouter-image'
);
throw new \RuntimeException(
sprintf(
esc_html__( 'OpenRouter image error (HTTP %1$d): %2$s', 'prautoblogger' ),
$status,
esc_html( substr( $raw, 0, 300 ) )
)
);
}
$image_bytes = $this->support->extract_image_bytes( $raw );
return [
'bytes' => $image_bytes,
'mime_type' => 'image/png',
'width' => $width,
'height' => $height,
'model' => $model,
'seed' => null,
'cost_usd' => $this->pricing->estimate_cost( $width, $height, $model ),
'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ),
];
}
} finally {
remove_action( 'http_api_curl', $curl_filter, 99 );
}
throw new \RuntimeException(
sprintf(
esc_html__( 'OpenRouter image gen failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ),
PRAUTOBLOGGER_MAX_RETRIES,
esc_html( $last_error )
)
);
}
/** {@inheritDoc} */
public function estimate_cost( int $width, int $height, array $options = [] ): float {
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
return $this->pricing->estimate_cost( $width, $height, $model );
}
/** {@inheritDoc} */
public function get_provider_name(): string {
return self::PROVIDER_ID;
}
/**
* Verify credentials by calling OpenRouter's /auth/key endpoint.
*
* {@inheritDoc}
*/
public function validate_credentials_detailed(): array {
try {
$api_key = $this->support->get_api_key();
} catch ( \RuntimeException $e ) {
return [
'status' => 'error',
'message' => $e->getMessage(),
];
}
$response = wp_remote_get( 'https://openrouter.ai/api/v1/auth/key', [
'timeout' => 15,
'headers' => [ 'Authorization' => 'Bearer ' . $api_key ],
] );
if ( is_wp_error( $response ) ) {
return [
'status' => 'error',
'message' => __( 'Could not reach OpenRouter API.', 'prautoblogger' ),
'debug' => $response->get_error_message(),
];
}
$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' => 'error',
'message' => sprintf( __( 'OpenRouter returned HTTP %d.', 'prautoblogger' ), $status ),
'debug' => substr( (string) wp_remote_retrieve_body( $response ), 0, 200 ),
];
}
/**
* 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>
*/
private function build_request_body( string $prompt, int $width, int $height, string $model ): array {
$gcd = $this->support->gcd( $width, $height );
$ar_w = (int) ( $width / $gcd );
$ar_h = (int) ( $height / $gcd );
$aspect = $ar_w . ':' . $ar_h;
return [
'model' => $model,
'messages' => [
[
'role' => 'user',
'content' => $prompt,
],
],
'modalities' => [ 'image' ],
'image_config' => [
'aspect_ratio' => $aspect,
],
];
}
/**
* Enforce the interface's dimension contract.
*
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @throws \InvalidArgumentException If outside [MIN, MAX].
*/
private function assert_valid_dimensions( int $width, int $height ): void {
if (
$width < self::MIN_DIMENSION_PX || $width > self::MAX_DIMENSION_PX ||
$height < self::MIN_DIMENSION_PX || $height > self::MAX_DIMENSION_PX
) {
throw new \InvalidArgumentException(
sprintf(
esc_html__( 'Image dimensions %1$dx%2$d outside supported range (%3$d–%4$d px).', 'prautoblogger' ),
$width, $height, self::MIN_DIMENSION_PX, self::MAX_DIMENSION_PX
)
);
}
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
/**
* Support helpers for the OpenRouter image provider — API key lookup,
* response parsing, retry backoff, and logging.
*
* Split from the provider to keep both files under the 300-line cap.
*
* Triggered by: PRAutoBlogger_OpenRouter_Image_Provider only.
* Dependencies: PRAutoBlogger_Encryption, PRAutoBlogger_Logger.
*
* @see class-openrouter-image-provider.php — Sole caller.
*/
class PRAutoBlogger_OpenRouter_Image_Support {
/**
* Get the decrypted OpenRouter API key from settings.
*
* @return string Plaintext key.
* @throws \RuntimeException If key is not configured or decrypt fails.
*/
public function get_api_key(): string {
$stored = (string) get_option( 'prautoblogger_openrouter_api_key', '' );
if ( '' === $stored ) {
throw new \RuntimeException(
__( 'OpenRouter API key is not configured. Go to PRAutoBlogger → Settings.', 'prautoblogger' )
);
}
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
if ( '' === $decrypted ) {
throw new \RuntimeException(
__( 'OpenRouter API key could not be decrypted. Re-enter in Settings.', 'prautoblogger' )
);
}
return $decrypted;
}
return $stored;
}
/**
* Extract raw image bytes from OpenRouter's chat/completions response.
*
* The response contains choices[0].message.images[0].image_url.url
* as a data URI: "data:image/png;base64,{bytes}".
*
* @param string $raw Raw JSON response body.
* @return string Decoded image bytes.
* @throws \RuntimeException If the response doesn't contain an image.
*/
public function extract_image_bytes( string $raw ): string {
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) ) {
throw new \RuntimeException(
esc_html__( 'OpenRouter returned invalid JSON.', 'prautoblogger' )
);
}
$images = $decoded['choices'][0]['message']['images'] ?? [];
if ( empty( $images ) ) {
throw new \RuntimeException(
esc_html__( 'OpenRouter response contained no images.', 'prautoblogger' )
);
}
$data_url = $images[0]['image_url']['url'] ?? '';
if ( '' === $data_url ) {
throw new \RuntimeException(
esc_html__( 'OpenRouter image URL is empty.', 'prautoblogger' )
);
}
$comma_pos = strpos( $data_url, ',' );
if ( false === $comma_pos ) {
throw new \RuntimeException(
esc_html__( 'OpenRouter image data URI is malformed.', 'prautoblogger' )
);
}
$b64 = substr( $data_url, $comma_pos + 1 );
$bytes = base64_decode( $b64, true );
if ( false === $bytes || '' === $bytes ) {
throw new \RuntimeException(
esc_html__( 'OpenRouter image base64 decode failed.', 'prautoblogger' )
);
}
return $bytes;
}
/**
* Emit a WARN line for a retryable image-gen failure.
*
* @param int $attempt 1-based attempt number.
* @param string $class 'network' or HTTP status code.
* @param string $detail Short failure detail.
*/
public function log_retry( int $attempt, string $class, string $detail ): void {
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'OpenRouter image gen %s failure (attempt %d/%d): %s',
$class,
$attempt,
PRAUTOBLOGGER_MAX_RETRIES,
$detail
),
'openrouter-image'
);
}
/**
* Sleep between retries with exponential backoff.
*
* @param int $attempt 1-based attempt that just failed.
* @param int $retry_after Retry-After header value (0 if absent).
*/
public function backoff( int $attempt, int $retry_after = 0 ): void {
$delay = $retry_after > 0
? min( $retry_after, 60 )
: PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 );
if ( $delay > 0 ) {
sleep( $delay );
}
}
/**
* Greatest common divisor (for aspect ratio calculation).
*
* @param int $a First number.
* @param int $b Second number.
* @return int GCD.
*/
public function gcd( int $a, int $b ): int {
while ( 0 !== $b ) {
$t = $b;
$b = $a % $b;
$a = $t;
}
return max( $a, 1 );
}
}

View File

@@ -53,10 +53,11 @@ define( 'PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL', 'google/gemini-2.5-flash-lite' )
define( 'PRAUTOBLOGGER_DEFAULT_WRITING_MODEL', 'google/gemini-2.5-flash-lite' );
define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' );
// Image generation defaults (Cloudflare Workers AI, FLUX family).
// Short alias; the Cloudflare image pricing helper expands it to the full
// `@cf/black-forest-labs/...` model id when building the Workers AI URL.
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'flux-1-schnell' );
// Image generation defaults.
// Default provider is OpenRouter (higher quality); Cloudflare is the fallback.
// Default model is FLUX.2 Pro via OpenRouter (~$0.03/image, good quality).
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'openrouter' );
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'black-forest-labs/flux-2-pro' );
// CEO-approved image style suffix (decision locked 2026-04-15). Users may
// override via Settings → Images → Style Suffix; this constant is the

View File

@@ -0,0 +1,82 @@
<?php
/**
* Tests for PRAutoBlogger_OpenRouter_Image_Pricing.
*
* Validates model resolution fallback logic and per-image cost estimation.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class OpenRouterImagePricingTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [];
return $options[ $key ] ?? $default;
} );
}
/** Default model constant is FLUX.2 Pro. */
public function test_default_model_constant(): void {
$this->assertSame(
'black-forest-labs/flux-2-pro',
\PRAutoBlogger_OpenRouter_Image_Pricing::DEFAULT_MODEL
);
}
/** Empty hint resolves to default model. */
public function test_resolve_model_empty_hint_uses_default(): void {
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
$model = $pricing->resolve_model( '' );
$this->assertSame( 'black-forest-labs/flux-2-pro', $model );
}
/** Valid hint resolves to itself. */
public function test_resolve_model_valid_hint(): void {
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
$model = $pricing->resolve_model( 'bytedance/seedream-3.0' );
$this->assertSame( 'bytedance/seedream-3.0', $model );
}
/** Unknown model falls back to default. */
public function test_resolve_model_unknown_falls_back_to_default(): void {
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
$model = $pricing->resolve_model( 'nonexistent/model-999' );
$this->assertSame( 'black-forest-labs/flux-2-pro', $model );
}
/** FLUX.2 Pro costs $0.03/image. */
public function test_estimate_cost_flux2_pro(): void {
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
$cost = $pricing->estimate_cost( 1200, 632, 'black-forest-labs/flux-2-pro' );
$this->assertEqualsWithDelta( 0.03, $cost, 0.000001 );
}
/** Seedream costs $0.003/image. */
public function test_estimate_cost_seedream(): void {
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
$cost = $pricing->estimate_cost( 1200, 632, 'bytedance/seedream-3.0' );
$this->assertEqualsWithDelta( 0.003, $cost, 0.000001 );
}
/** Unknown model in estimate_cost falls back to default pricing. */
public function test_estimate_cost_unknown_model(): void {
$pricing = new \PRAutoBlogger_OpenRouter_Image_Pricing();
$cost = $pricing->estimate_cost( 1200, 632, 'unknown/model' );
$this->assertEqualsWithDelta( 0.03, $cost, 0.000001 );
}
/** get_model_costs returns a non-empty map. */
public function test_get_model_costs(): void {
$costs = \PRAutoBlogger_OpenRouter_Image_Pricing::get_model_costs();
$this->assertIsArray( $costs );
$this->assertArrayHasKey( 'black-forest-labs/flux-2-pro', $costs );
$this->assertGreaterThan( 0, count( $costs ) );
}
}

View File

@@ -0,0 +1,154 @@
<?php
/**
* Tests for PRAutoBlogger_OpenRouter_Image_Provider.
*
* Validates provider interface compliance, response parsing, and error handling.
* All HTTP calls are mocked — no real API calls.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class OpenRouterImageProviderTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
// Provide a valid encrypted API key.
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [
'prautoblogger_openrouter_api_key' => 'enc:fake-encrypted-key',
'prautoblogger_image_model' => 'black-forest-labs/flux-2-pro',
'prautoblogger_ai_gateway_base_url' => '',
];
return $options[ $key ] ?? $default;
} );
Functions\when( 'is_wp_error' )->alias( function ( $thing ) {
return $thing instanceof \WP_Error;
} );
Functions\when( 'esc_html' )->returnArg();
Functions\when( 'esc_html__' )->returnArg();
// Mock encryption: decrypt returns a valid sk-or- key.
if ( ! class_exists( 'PRAutoBlogger_Encryption' ) ) {
eval( '
class PRAutoBlogger_Encryption {
public static function is_encrypted( $v ) { return 0 === strpos( $v, "enc:" ); }
public static function decrypt( $v ) { return "sk-or-test-key-123"; }
public static function encrypt( $v ) { return "enc:" . $v; }
}
' );
}
}
/** Provider name matches expected identifier. */
public function test_get_provider_name(): void {
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
$this->assertSame( 'openrouter', $provider->get_provider_name() );
}
/** estimate_cost returns a positive float. */
public function test_estimate_cost_returns_float(): void {
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
$cost = $provider->estimate_cost( 1200, 632 );
$this->assertIsFloat( $cost );
$this->assertGreaterThan( 0, $cost );
}
/** Empty prompt throws InvalidArgumentException. */
public function test_generate_image_empty_prompt_throws(): void {
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( '', 1200, 632 );
}
/** Dimension below minimum throws InvalidArgumentException. */
public function test_generate_image_tiny_dimensions_throws(): void {
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( 'A test prompt', 10, 10 );
}
/** Dimension above maximum throws InvalidArgumentException. */
public function test_generate_image_huge_dimensions_throws(): void {
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( 'A test prompt', 5000, 5000 );
}
/** Successful generation returns correct array structure. */
public function test_generate_image_success(): void {
// Build a fake OpenRouter response with base64 image data.
$fake_bytes = 'fake-image-data';
$b64 = base64_encode( $fake_bytes );
$data_url = 'data:image/png;base64,' . $b64;
$body = wp_json_encode( [
'choices' => [ [
'message' => [
'role' => 'assistant',
'content' => '',
'images' => [ [
'type' => 'image_url',
'image_url' => [ 'url' => $data_url ],
] ],
],
] ],
] );
Functions\when( 'wp_remote_post' )->justReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $body,
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body );
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
Functions\when( 'wp_parse_url' )->alias( function ( $url, $component = -1 ) {
return parse_url( $url, $component );
} );
Functions\when( 'add_action' )->justReturn( true );
Functions\when( 'remove_action' )->justReturn( true );
Functions\when( 'home_url' )->justReturn( 'https://peptiderepo.com' );
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
$result = $provider->generate_image( 'A peptide research infographic', 1200, 632 );
$this->assertArrayHasKey( 'bytes', $result );
$this->assertArrayHasKey( 'mime_type', $result );
$this->assertArrayHasKey( 'model', $result );
$this->assertArrayHasKey( 'cost_usd', $result );
$this->assertArrayHasKey( 'latency_ms', $result );
$this->assertSame( $fake_bytes, $result['bytes'] );
$this->assertSame( 'image/png', $result['mime_type'] );
$this->assertSame( 1200, $result['width'] );
$this->assertSame( 632, $result['height'] );
}
/** 4xx error throws RuntimeException. */
public function test_generate_image_4xx_throws(): void {
Functions\when( 'wp_remote_post' )->justReturn( [
'response' => [ 'code' => 401 ],
'headers' => [],
'body' => '{"error":"unauthorized"}',
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"error":"unauthorized"}' );
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
Functions\when( 'wp_parse_url' )->alias( function ( $url, $component = -1 ) {
return parse_url( $url, $component );
} );
Functions\when( 'add_action' )->justReturn( true );
Functions\when( 'remove_action' )->justReturn( true );
Functions\when( 'home_url' )->justReturn( 'https://peptiderepo.com' );
$provider = new \PRAutoBlogger_OpenRouter_Image_Provider();
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'HTTP 401' );
$provider->generate_image( 'A test prompt', 1200, 632 );
}
}