diff --git a/assets/js/model-picker.js b/assets/js/model-picker.js
index facc29f..a2e268a 100644
--- a/assets/js/model-picker.js
+++ b/assets/js/model-picker.js
@@ -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 = $('
');
var $popup = $(
diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php
index a32b0e5..728f478 100755
--- a/includes/admin/class-admin-page.php
+++ b/includes/admin/class-admin-page.php
@@ -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(),
] );
}
diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php
index 4178c2c..a9ace96 100644
--- a/includes/admin/class-settings-fields-extended.php
+++ b/includes/admin/class-settings-fields-extended.php
@@ -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>
+ */
+ 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',
diff --git a/includes/admin/class-settings-fields.php b/includes/admin/class-settings-fields.php
index 3b059dd..21f4e47 100755
--- a/includes/admin/class-settings-fields.php
+++ b/includes/admin/class-settings-fields.php
@@ -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' ),
],
];
}
diff --git a/includes/core/class-image-pipeline.php b/includes/core/class-image-pipeline.php
index 7dafb7b..b5a9e59 100644
--- a/includes/core/class-image-pipeline.php
+++ b/includes/core/class-image-pipeline.php
@@ -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'
);
diff --git a/includes/providers/class-openrouter-image-pricing.php b/includes/providers/class-openrouter-image-pricing.php
new file mode 100644
index 0000000..1beb5c0
--- /dev/null
+++ b/includes/providers/class-openrouter-image-pricing.php
@@ -0,0 +1,79 @@
+
+ */
+ 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 Model id => cost per image in USD.
+ */
+ public static function get_model_costs(): array {
+ return self::COST_PER_IMAGE;
+ }
+}
diff --git a/includes/providers/class-openrouter-image-provider.php b/includes/providers/class-openrouter-image-provider.php
new file mode 100644
index 0000000..b85706e
--- /dev/null
+++ b/includes/providers/class-openrouter-image-provider.php
@@ -0,0 +1,266 @@
+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
+ */
+ 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
+ )
+ );
+ }
+ }
+}
diff --git a/includes/providers/class-openrouter-image-support.php b/includes/providers/class-openrouter-image-support.php
new file mode 100644
index 0000000..e611f7e
--- /dev/null
+++ b/includes/providers/class-openrouter-image-support.php
@@ -0,0 +1,144 @@
+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 );
+ }
+}
diff --git a/prautoblogger.php b/prautoblogger.php
index d1efc71..e2628d3 100644
--- a/prautoblogger.php
+++ b/prautoblogger.php
@@ -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
diff --git a/tests/unit/Providers/OpenRouterImagePricingTest.php b/tests/unit/Providers/OpenRouterImagePricingTest.php
new file mode 100644
index 0000000..9c9e969
--- /dev/null
+++ b/tests/unit/Providers/OpenRouterImagePricingTest.php
@@ -0,0 +1,82 @@
+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 ) );
+ }
+}
diff --git a/tests/unit/Providers/OpenRouterImageProviderTest.php b/tests/unit/Providers/OpenRouterImageProviderTest.php
new file mode 100644
index 0000000..e062bb9
--- /dev/null
+++ b/tests/unit/Providers/OpenRouterImageProviderTest.php
@@ -0,0 +1,154 @@
+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 );
+ }
+}