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 ); + } +}