From 1c3d9b50c63ef2b7dc67229e937618dad5024821 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Tue, 21 Apr 2026 16:45:23 +0000 Subject: [PATCH 1/6] cf-removal: delete Cloudflare Workers AI provider files + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the four CF provider classes and their two test files. The OpenRouter AI Gateway path is unaffected — it reads prautoblogger_ai_gateway_base_url via class-open-router-config.php directly, with no dependency on the CF support class. Leaves class-cost-tracker.php line 165 'cloudflare-workers-ai' label intact for historical log integrity. Part of v0.10.0 (removing CF Workers AI as image provider). See convo/prautoblogger/threads/2026-04-cloudflare-workers-ai-removal/. --- .../class-cloudflare-image-pricing.php | 78 --- .../class-cloudflare-image-provider.php | 296 ----------- .../class-cloudflare-image-support.php | 229 --------- .../class-cloudflare-image-validator.php | 122 ----- .../Providers/CloudflareImageProviderTest.php | 483 ------------------ .../Providers/CloudflareImageSupportTest.php | 115 ----- 6 files changed, 1323 deletions(-) delete mode 100644 includes/providers/class-cloudflare-image-pricing.php delete mode 100644 includes/providers/class-cloudflare-image-provider.php delete mode 100644 includes/providers/class-cloudflare-image-support.php delete mode 100644 includes/providers/class-cloudflare-image-validator.php delete mode 100644 tests/unit/Providers/CloudflareImageProviderTest.php delete mode 100644 tests/unit/Providers/CloudflareImageSupportTest.php diff --git a/includes/providers/class-cloudflare-image-pricing.php b/includes/providers/class-cloudflare-image-pricing.php deleted file mode 100644 index b1b2e6f..0000000 --- a/includes/providers/class-cloudflare-image-pricing.php +++ /dev/null @@ -1,78 +0,0 @@ - - */ - private const MODEL_ALIAS_MAP = [ - 'flux-1-schnell' => '@cf/black-forest-labs/flux-1-schnell', - ]; - - /** - * Normalize a caller's model hint to a fully-qualified Workers AI id. - * - * Accepts short aliases (`flux-1-schnell`), fully-qualified names - * (`@cf/black-forest-labs/...`), or empty. Empty input falls back to - * the site option, then to the hardcoded default. - * - * @param string $hint Caller-supplied model identifier (may be empty). - * @return string Fully-qualified Workers AI model id suitable for the URL path. - */ - public function resolve_model( string $hint ): string { - $hint = trim( $hint ); - if ( '' === $hint ) { - $hint = (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL ); - } - if ( 0 === strpos( $hint, '@cf/' ) ) { - return $hint; - } - return self::MODEL_ALIAS_MAP[ $hint ] ?? self::MODEL_ALIAS_MAP['flux-1-schnell']; - } - - /** - * Estimate USD cost for a single image at the given dimensions. - * - * FLUX is priced per megapixel of output. We clamp the MP floor at - * 0.01 so a tiny thumbnail doesn't round to zero and bypass the - * budget check. - * - * @param int $width Width in pixels. - * @param int $height Height in pixels. - * @param string $model Fully-qualified Workers AI model id (call resolve_model first). - * @return float USD cost rounded to 6 decimals. - */ - public function estimate_cost( int $width, int $height, string $model ): float { - $mp = max( 0.01, ( $width * $height ) / 1_000_000 ); - $rate = self::COST_PER_MP_SCHNELL_USD; - return round( $mp * $rate, 6 ); - } -} diff --git a/includes/providers/class-cloudflare-image-provider.php b/includes/providers/class-cloudflare-image-provider.php deleted file mode 100644 index 161a439..0000000 --- a/includes/providers/class-cloudflare-image-provider.php +++ /dev/null @@ -1,296 +0,0 @@ -pricing = new PRAutoBlogger_Cloudflare_Image_Pricing(); - $this->support = new PRAutoBlogger_Cloudflare_Image_Support(); - } - - /** - * Generate a single image from a prompt at the requested dimensions. - * - * Retries 5xx / 429 / network errors with exponential backoff bounded - * by PRAUTOBLOGGER_MAX_RETRIES. 4xx other than 429 fail loudly on the - * first attempt — retrying a bad prompt or a bad token wastes money. - * - * Side effects: one HTTP request per attempt; one Logger line per attempt. - * - * {@inheritDoc} - * - * @throws \InvalidArgumentException If dimensions out of range or prompt empty. - * @throws \RuntimeException On auth failure, bad response shape, or retry exhaustion. - */ - public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array { - // FLUX.1 requires dimensions divisible by 8; round to nearest valid value. - $width = (int) ( round( $width / 8 ) * 8 ); - $height = (int) ( round( $height / 8 ) * 8 ); - - $this->assert_valid_dimensions( $width, $height ); - $prompt = trim( $prompt ); - if ( '' === $prompt ) { - throw new \InvalidArgumentException( - esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' ) - ); - } - - $account_id = $this->support->get_account_id(); - $api_token = $this->support->get_api_token(); - $model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) ); - - $body = [ - 'prompt' => $prompt, - 'width' => $width, - 'height' => $height, - 'steps' => (int) ( $options['steps'] ?? self::DEFAULT_STEPS_SCHNELL ), - ]; - if ( isset( $options['seed'] ) ) { - $body['seed'] = (int) $options['seed']; - } - - $url = $this->support->build_endpoint_url( $account_id, $model ); - $headers = [ - 'Authorization' => 'Bearer ' . $api_token, - 'Content-Type' => 'application/json', - 'Accept' => 'image/*', - ]; - $started_at = microtime( true ); - $last_error = ''; - - 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_retryable_failure( $attempt, 'network', $last_error ); - $this->sleep_for_attempt( $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_retryable_failure( $attempt, (string) $status, $last_error ); - if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) { - $retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' ); - $this->sleep_for_attempt( $attempt, $retry_after ); - } - continue; - } - - if ( $status >= 400 ) { - $this->support->log_client_error( $status, $api_token, $account_id, $raw ); - $this->support->throw_if_nsfw( $raw ); - throw new \RuntimeException( - sprintf( - /* translators: %1$d: HTTP status, %2$s: error body. */ - esc_html__( 'Cloudflare Workers AI error (HTTP %1$d): %2$s', 'prautoblogger' ), - $status, - esc_html( substr( $raw, 0, 300 ) ) - ) - ); - } - - // Success. FLUX on Workers AI returns either raw image bytes - // (Content-Type: image/*) or a JSON envelope with base64 under - // `result.image`. Handle both without branching the caller. - $mime = (string) wp_remote_retrieve_header( $response, 'content-type' ); - [ $raw, $mime ] = $this->normalize_response_body( $raw, $mime ); - - return [ - 'bytes' => $raw, - 'mime_type' => $mime, - 'width' => $width, - 'height' => $height, - 'model' => $model, - 'seed' => isset( $body['seed'] ) ? (int) $body['seed'] : null, - 'cost_usd' => $this->pricing->estimate_cost( $width, $height, $model ), - 'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ), - ]; - } - - throw new \RuntimeException( - sprintf( - /* translators: %1$d: max retries, %2$s: last error. */ - esc_html__( 'Cloudflare Workers AI failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ), - PRAUTOBLOGGER_MAX_RETRIES, - esc_html( $last_error ) - ) - ); - } - - /** - * Sequential fallback — Cloudflare Workers AI does not support batching. - * - * {@inheritDoc} - */ - public function generate_image_batch( array $requests ): array { - $results = []; - foreach ( $requests as $key => $req ) { - try { - $results[ $key ] = $this->generate_image( $req['prompt'], $req['width'], $req['height'], $req['options'] ?? [] ); - } catch ( PRAutoBlogger_Image_NSFW_Blocked $e ) { - // Tag NSFW so the pipeline's retry layer can rebuild the prompt. - $results[ $key ] = [ 'error' => $e->getMessage(), 'error_type' => 'nsfw_blocked' ]; - } catch ( \Throwable $e ) { - $results[ $key ] = [ 'error' => $e->getMessage() ]; - } - } - return $results; - } - - /** - * Estimate the USD cost of a single generation call. - * - * {@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 ); - } - - /** - * @return string Machine-readable provider id used in logs and settings. - */ - public function get_provider_name(): string { - return self::PROVIDER_ID; - } - - /** - * Verify credentials are present and the API is reachable. - * - * Delegates to PRAutoBlogger_Cloudflare_Image_Validator so the HTTP probe - * and error-shape code can be tested and sized independently. - * - * Side effects: one HTTP GET via the validator; one Logger line on failure. - * - * @return array{status: string, message: string, debug?: string} - */ - public function validate_credentials_detailed(): array { - return ( new PRAutoBlogger_Cloudflare_Image_Validator() )->run(); - } - - /** - * Enforce the interface's dimension contract. - * - * @param int $width Width in pixels. - * @param int $height Height in pixels. - * @return void - * @throws \InvalidArgumentException If either dimension is 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( - /* translators: %1$d: width, %2$d: height, %3$d: min, %4$d: max. */ - esc_html__( 'Image dimensions %1$dx%2$d are outside the supported range (%3$d–%4$d px per axis).', 'prautoblogger' ), - $width, - $height, - self::MIN_DIMENSION_PX, - self::MAX_DIMENSION_PX - ) - ); - } - } - - /** - * Normalize a 2xx response into [bytes, mime-type] whether the body - * arrives as raw image bytes or as a JSON envelope with base64 image. - * - * @param string $raw Raw response body. - * @param string $mime Response Content-Type header value (may be empty). - * @return array{0: string, 1: string} [image bytes, mime type] - * @throws \RuntimeException If the body matches neither expected shape. - */ - private function normalize_response_body( string $raw, string $mime ): array { - if ( '' !== $mime && 0 === stripos( $mime, 'image/' ) ) { - return [ $raw, $mime ]; - } - $decoded = json_decode( $raw, true ); - if ( is_array( $decoded ) && isset( $decoded['result']['image'] ) && is_string( $decoded['result']['image'] ) ) { - $bytes = (string) base64_decode( $decoded['result']['image'], true ); - if ( '' !== $bytes ) { - return [ $bytes, 'image/png' ]; - } - } - throw new \RuntimeException( - esc_html__( 'Cloudflare Workers AI returned an unexpected response shape.', 'prautoblogger' ) - ); - } - - /** - * Sleep between retries. Retry-After header value wins when present, - * otherwise exponential backoff from the PRAUTOBLOGGER_* constants. - * - * @param int $attempt 1-based attempt number that just failed. - * @param int $retry_after_secs Retry-After header value (0 if absent). - * @return void - */ - private function sleep_for_attempt( int $attempt, int $retry_after_secs = 0 ): void { - $backoff = PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 ); - $delay = $retry_after_secs > 0 ? min( $retry_after_secs, self::RETRY_AFTER_CAP_S ) : $backoff; - if ( $delay > 0 ) { - sleep( $delay ); - } - } - -} diff --git a/includes/providers/class-cloudflare-image-support.php b/includes/providers/class-cloudflare-image-support.php deleted file mode 100644 index bd29b31..0000000 --- a/includes/providers/class-cloudflare-image-support.php +++ /dev/null @@ -1,229 +0,0 @@ -build_gateway_workers_ai_url( $account_id, $model ); - if ( '' !== $gateway_url ) { - return $gateway_url; - } - return sprintf( - 'https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s', - rawurlencode( $account_id ), - ltrim( $model, '/' ) - ); - } - - /** - * Derive a Workers AI URL rooted at the same AI Gateway we already use - * for OpenRouter. Returns empty string when the gateway isn't configured - * or the admin toggle opts out — the caller falls back to direct API. - * - * The existing `prautoblogger_ai_gateway_base_url` option points at - * `https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/openrouter` - * for the LLM path; we strip the `/openrouter` suffix and append - * `/workers-ai/{model}` for the image path. Defensive: only emits a URL - * that pattern-matches, so a bad option value quietly falls back. - * - * @param string $account_id Cloudflare account UUID (kept for symmetry; not used in URL derivation). - * @param string $model Fully-qualified Workers AI model id. - * @return string Gateway URL, or empty string if unavailable. - */ - private function build_gateway_workers_ai_url( string $account_id, string $model ): string { - unset( $account_id ); - if ( '1' !== (string) get_option( 'prautoblogger_cf_image_via_gateway', '1' ) ) { - return ''; - } - $gateway_base = rtrim( (string) get_option( 'prautoblogger_ai_gateway_base_url', '' ), '/' ); - if ( '' === $gateway_base ) { - return ''; - } - // Must be an https CF AI Gateway URL. - if ( 0 !== stripos( $gateway_base, 'https://gateway.ai.cloudflare.com/' ) ) { - return ''; - } - // The configured URL ends with `/openrouter` (LLM route). The gateway - // root — shared with the Workers AI route — is everything before the - // last `/`. If no `/` is present (defensive), we drop back to direct API. - $last_slash = strrpos( $gateway_base, '/' ); - if ( false === $last_slash ) { - return ''; - } - $gateway_root = substr( $gateway_base, 0, $last_slash ); - if ( '' === $gateway_root ) { - return ''; - } - return sprintf( '%s/workers-ai/%s', $gateway_root, ltrim( $model, '/' ) ); - } - - /** - * Emit a WARN line for a retryable failure. - * - * @param int $attempt 1-based attempt number. - * @param string $failure_class Either 'network' or HTTP status code as string. - * @param string $detail Short failure detail for the log. - * @return void - */ - public function log_retryable_failure( int $attempt, string $failure_class, string $detail ): void { - PRAutoBlogger_Logger::instance()->warning( - sprintf( - 'Cloudflare image gen %s failure (attempt %d/%d): %s', - $failure_class, - $attempt, - PRAUTOBLOGGER_MAX_RETRIES, - $detail - ), - 'cloudflare-image' - ); - } - - /** - * Log an unrecoverable 4xx with triage context. Never logs the full - * secret — only a 4-char token prefix and a 6-char account prefix. - * - * @param int $status HTTP status returned by Cloudflare. - * @param string $api_token Plaintext token. - * @param string $account_id Account UUID. - * @param string $raw Raw response body (truncated to 300 chars in the log). - * @return void - */ - public function log_client_error( int $status, string $api_token, string $account_id, string $raw ): void { - PRAutoBlogger_Logger::instance()->error( - sprintf( - 'Cloudflare image gen HTTP %d (token_prefix=%s, token_len=%d, account=%s): %s', - $status, - substr( $api_token, 0, 4 ), - strlen( $api_token ), - substr( $account_id, 0, 6 ), - substr( $raw, 0, 300 ) - ), - 'cloudflare-image' - ); - } - - /** - * Cloudflare Workers AI error code for NSFW content rejection. - * - * Observed on FLUX.1 schnell on 2026-04-20 when backfilling the post - * "Semaglutide vs Tirzepatide". Documented upstream as - * `AiError: Input prompt contains NSFW content.` - */ - public const ERROR_CODE_NSFW = 3030; - - /** - * Return true when the Cloudflare response body contains the NSFW - * content-filter error code (3030). - * - * The body shape is `{"errors":[{"code":3030,"message":"..."}],...}`. - * Parsing is tolerant: any JSON decode failure or missing key returns - * false, so a malformed 4xx never masquerades as NSFW. - * - * @param string $raw Raw response body from Cloudflare. - * @return bool True when at least one error entry carries code 3030. - */ - public function is_nsfw_error( string $raw ): bool { - $decoded = json_decode( $raw, true ); - if ( ! is_array( $decoded ) || empty( $decoded['errors'] ) || ! is_array( $decoded['errors'] ) ) { - return false; - } - foreach ( $decoded['errors'] as $err ) { - if ( is_array( $err ) && (int) ( $err['code'] ?? 0 ) === self::ERROR_CODE_NSFW ) { - return true; - } - } - return false; - } - - /** - * Throw a typed NSFW exception when the response body signals CF's - * content-filter rejection (code 3030). No-op otherwise. - * - * Centralised so callers stay at one line at the call site and so the - * exception-construction shape can't drift between call sites. - * - * @param string $raw Raw response body. - * @return void - * @throws PRAutoBlogger_Image_NSFW_Blocked When the body contains code 3030. - */ - public function throw_if_nsfw( string $raw ): void { - if ( ! $this->is_nsfw_error( $raw ) ) { - return; - } - throw new PRAutoBlogger_Image_NSFW_Blocked( - sprintf( - /* translators: %s: truncated error body. */ - esc_html__( 'Cloudflare Workers AI rejected the prompt as NSFW: %s', 'prautoblogger' ), - esc_html( substr( $raw, 0, 300 ) ) - ), - $raw - ); - } -} diff --git a/includes/providers/class-cloudflare-image-validator.php b/includes/providers/class-cloudflare-image-validator.php deleted file mode 100644 index 1aa295d..0000000 --- a/includes/providers/class-cloudflare-image-validator.php +++ /dev/null @@ -1,122 +0,0 @@ -err( - 'account_id_empty', - __( 'Cloudflare account ID is not set. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' ) - ); - } - - $stored = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' ); - $api_token = ''; - if ( '' !== $stored ) { - // Handle legacy plaintext tokens (saved before the field was in the encrypted list). - $api_token = PRAutoBlogger_Encryption::is_encrypted( $stored ) - ? (string) PRAutoBlogger_Encryption::decrypt( $stored ) - : $stored; - } - if ( '' === $api_token ) { - return $this->err( - 'token_empty', - __( 'Cloudflare API token is not set. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' ) - ); - } - - $response = wp_remote_get( - sprintf( - 'https://api.cloudflare.com/client/v4/accounts/%s/ai/models/search?per_page=1', - rawurlencode( $account_id ) - ), - [ - 'timeout' => self::TIMEOUT_SECONDS, - 'headers' => [ - 'Authorization' => 'Bearer ' . $api_token, - 'Content-Type' => 'application/json', - ], - ] - ); - - if ( is_wp_error( $response ) ) { - $msg = $response->get_error_message(); - PRAutoBlogger_Logger::instance()->error( - sprintf( 'Cloudflare credential check wp_error: %s', $msg ), - 'cloudflare-image' - ); - return $this->err( - 'wp_error:' . $msg, - sprintf( - /* translators: %s: transport-layer error message. */ - esc_html__( 'Network error reaching Cloudflare: %s', 'prautoblogger' ), - esc_html( $msg ) - ) - ); - } - - $status = (int) wp_remote_retrieve_response_code( $response ); - if ( 200 === $status ) { - return [ - 'status' => 'ok', - 'message' => __( 'Cloudflare Workers AI connected.', 'prautoblogger' ), - ]; - } - - $body = (string) wp_remote_retrieve_body( $response ); - PRAutoBlogger_Logger::instance()->warning( - sprintf( 'Cloudflare credential check HTTP %d: %s', $status, substr( $body, 0, 200 ) ), - 'cloudflare-image' - ); - return $this->err( - sprintf( 'http_%d', $status ), - sprintf( - /* translators: %1$d: HTTP status, %2$s: response body. */ - esc_html__( 'Cloudflare returned HTTP %1$d. %2$s', 'prautoblogger' ), - $status, - esc_html( substr( $body, 0, 200 ) ) - ) - ); - } - - /** - * @return array{status: string, message: string, debug: string} - */ - private function err( string $debug, string $message ): array { - return [ - 'status' => 'error', - 'message' => $message, - 'debug' => $debug, - ]; - } -} diff --git a/tests/unit/Providers/CloudflareImageProviderTest.php b/tests/unit/Providers/CloudflareImageProviderTest.php deleted file mode 100644 index 3f97a52..0000000 --- a/tests/unit/Providers/CloudflareImageProviderTest.php +++ /dev/null @@ -1,483 +0,0 @@ -stub_get_option( [ - 'prautoblogger_cloudflare_ai_token' => '', - 'prautoblogger_cloudflare_account_id' => 'test-account-uuid', - 'prautoblogger_image_model' => 'flux-1-schnell', - 'prautoblogger_log_level' => 'info', - ] ); - - Functions\when( 'wp_salt' )->justReturn( 'test_salt_key_for_unit_tests' ); - Functions\when( 'is_wp_error' )->justReturn( false ); - Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' ); - } - - /** - * Helper: configure the plaintext API token the provider will eventually - * see after decryption. We round-trip through the real Encryption class - * (rather than stubbing it) so this test catches any regressions in the - * encrypt → option → decrypt flow at the same time. - */ - private function with_token( string $plaintext ): void { - $ciphertext = \PRAutoBlogger_Encryption::encrypt( $plaintext ); - $this->stub_get_option( [ - 'prautoblogger_cloudflare_ai_token' => $ciphertext, - 'prautoblogger_cloudflare_account_id' => 'test-account-uuid', - 'prautoblogger_image_model' => 'flux-1-schnell', - 'prautoblogger_log_level' => 'info', - ] ); - } - - /** - * Provider implements the interface. - */ - public function test_provider_implements_interface(): void { - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - $this->assertInstanceOf( \PRAutoBlogger_Image_Provider_Interface::class, $provider ); - } - - /** - * get_provider_name returns the stable machine identifier. - */ - public function test_provider_name_is_stable_identifier(): void { - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - $this->assertSame( 'cloudflare_workers_ai', $provider->get_provider_name() ); - } - - /** - * Dimension validation rejects inputs outside the [64, 2048] envelope - * before any HTTP call is made. - */ - public function test_generate_image_rejects_out_of_range_dimensions(): void { - $this->with_token( 'test-token' ); - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - - $this->expectException( \InvalidArgumentException::class ); - $provider->generate_image( 'a prompt', 32, 1080 ); - } - - /** - * Empty prompts are rejected — an empty string burns budget on - * Workers AI for a deterministic "bad request" result. - */ - public function test_generate_image_rejects_empty_prompt(): void { - $this->with_token( 'test-token' ); - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - - $this->expectException( \InvalidArgumentException::class ); - $provider->generate_image( ' ', 1080, 1080 ); - } - - /** - * Happy path — raw image bytes come back with expected metadata shape. - */ - public function test_generate_image_returns_raw_bytes_and_metadata(): void { - $this->with_token( 'test-token' ); - - Functions\when( 'wp_remote_post' )->justReturn( [ - 'body' => self::FAKE_IMAGE_BYTES, - 'response' => [ 'code' => 200 ], - 'headers' => [ 'content-type' => 'image/png' ], - ] ); - Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); - Functions\when( 'wp_remote_retrieve_body' )->justReturn( self::FAKE_IMAGE_BYTES ); - Functions\when( 'wp_remote_retrieve_header' )->alias( - function ( $_response, $name ) { - return 'content-type' === strtolower( (string) $name ) ? 'image/png' : ''; - } - ); - - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - $result = $provider->generate_image( 'a prompt', 1080, 1080, [ 'seed' => 42 ] ); - - $this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] ); - $this->assertSame( 'image/png', $result['mime_type'] ); - $this->assertSame( 1080, $result['width'] ); - $this->assertSame( 1080, $result['height'] ); - $this->assertSame( 42, $result['seed'] ); - $this->assertSame( '@cf/black-forest-labs/flux-1-schnell', $result['model'] ); - $this->assertGreaterThan( 0.0, $result['cost_usd'] ); - $this->assertGreaterThanOrEqual( 0, $result['latency_ms'] ); - } - - /** - * Happy path where Workers AI wraps the image in a JSON envelope with - * base64-encoded bytes under `result.image`. The provider must decode - * and return the raw bytes so the pipeline never has to branch. - */ - public function test_generate_image_decodes_json_envelope(): void { - $this->with_token( 'test-token' ); - - $encoded = base64_encode( self::FAKE_IMAGE_BYTES ); - $body = wp_json_encode( [ 'result' => [ 'image' => $encoded ], 'success' => true ] ); - - Functions\when( 'wp_remote_post' )->justReturn( [ - 'body' => $body, - 'response' => [ 'code' => 200 ], - 'headers' => [ 'content-type' => 'application/json' ], - ] ); - Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); - Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body ); - Functions\when( 'wp_remote_retrieve_header' )->alias( - function ( $_response, $name ) { - return 'content-type' === strtolower( (string) $name ) ? 'application/json' : ''; - } - ); - - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - $result = $provider->generate_image( 'a prompt', 600, 600 ); - - $this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] ); - $this->assertSame( 'image/png', $result['mime_type'] ); - } - - /** - * A 401 must NOT retry — auth errors are deterministic and retrying - * burns budget. The provider should throw on the first response. - * - * We can't assert "exactly one HTTP call" without injecting a call - * counter, so we assert that the throw message contains the status. - */ - public function test_generate_image_fails_loudly_on_401(): void { - $this->with_token( 'test-token' ); - - Functions\when( 'wp_remote_post' )->justReturn( [ - 'body' => '{"errors":[{"message":"bad token"}]}', - 'response' => [ 'code' => 401 ], - 'headers' => [], - ] ); - Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 ); - Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"bad token"}]}' ); - - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - - try { - $provider->generate_image( 'a prompt', 1080, 1080 ); - $this->fail( 'Expected RuntimeException for HTTP 401.' ); - } catch ( \RuntimeException $e ) { - $this->assertStringContainsString( '401', $e->getMessage() ); - } - } - - /** - * A 400 carrying Cloudflare's NSFW code (3030) must throw the typed - * PRAutoBlogger_Image_NSFW_Blocked exception, not a generic - * RuntimeException. Image_NSFW_Retry keys off the typed exception to - * decide whether to rebuild the prompt and retry. - */ - public function test_generate_image_throws_nsfw_blocked_on_code_3030(): void { - $this->with_token( 'test-token' ); - - $nsfw_body = '{"errors":[{"message":"AiError: Input prompt contains NSFW content.","code":3030}]}'; - - Functions\when( 'wp_remote_post' )->justReturn( [ - 'body' => $nsfw_body, - 'response' => [ 'code' => 400 ], - 'headers' => [], - ] ); - Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 400 ); - Functions\when( 'wp_remote_retrieve_body' )->justReturn( $nsfw_body ); - - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - $this->expectException( \PRAutoBlogger_Image_NSFW_Blocked::class ); - $provider->generate_image( 'some peptide prompt', 1080, 1080 ); - } - - /** - * A non-3030 4xx (e.g. 401 bad token) must NOT be tagged NSFW — the - * retry path would be wasted work there. - */ - public function test_generate_image_batch_tags_nsfw_errors_distinctly(): void { - $this->with_token( 'test-token' ); - - $nsfw_body = '{"errors":[{"message":"AiError: Input prompt contains NSFW content.","code":3030}]}'; - - Functions\when( 'wp_remote_post' )->justReturn( [ - 'body' => $nsfw_body, - 'response' => [ 'code' => 400 ], - 'headers' => [], - ] ); - Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 400 ); - Functions\when( 'wp_remote_retrieve_body' )->justReturn( $nsfw_body ); - - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - $results = $provider->generate_image_batch( [ - 'image_a' => [ 'prompt' => 'some prompt', 'width' => 1080, 'height' => 1080 ], - ] ); - - $this->assertArrayHasKey( 'image_a', $results ); - $this->assertSame( 'nsfw_blocked', $results['image_a']['error_type'] ?? null ); - $this->assertArrayHasKey( 'error', $results['image_a'] ); - } - - /** - * Response shape must be one of: image/* bytes OR JSON envelope with - * base64 image. Anything else is a contract breach — fail loudly. - */ - public function test_generate_image_throws_on_unexpected_response_shape(): void { - $this->with_token( 'test-token' ); - - Functions\when( 'wp_remote_post' )->justReturn( [ - 'body' => 'oops', - 'response' => [ 'code' => 200 ], - 'headers' => [ 'content-type' => 'text/html' ], - ] ); - Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); - Functions\when( 'wp_remote_retrieve_body' )->justReturn( 'oops' ); - Functions\when( 'wp_remote_retrieve_header' )->alias( - function ( $_response, $name ) { - return 'content-type' === strtolower( (string) $name ) ? 'text/html' : ''; - } - ); - - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - $this->expectException( \RuntimeException::class ); - $provider->generate_image( 'a prompt', 1080, 1080 ); - } - - /** - * Cost estimation is positive for in-range dimensions and scales with - * area — a 1080² image costs more than a 600² image on the same model. - */ - public function test_estimate_cost_scales_with_area(): void { - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - - $small = $provider->estimate_cost( 600, 600, [ 'model' => 'flux-1-schnell' ] ); - $large = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-schnell' ] ); - - $this->assertGreaterThan( 0.0, $small ); - $this->assertGreaterThan( $small, $large ); - } - - /** - * [dev] is meaningfully pricier than [schnell] at the same dimensions — - * the ~4x multiplier is the reason we default to schnell. - */ - public function test_estimate_cost_dev_more_expensive_than_schnell(): void { - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - - $schnell = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-schnell' ] ); - $dev = $provider->estimate_cost( 1080, 1080, [ 'model' => 'flux-1-dev' ] ); - - $this->assertGreaterThan( $schnell, $dev ); - } - - /** - * validate_credentials_detailed reports 'token_empty' debug tag when - * no token is configured, without making an HTTP call. - */ - public function test_validate_credentials_reports_missing_token(): void { - // Default setUp leaves token empty. - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - $result = $provider->validate_credentials_detailed(); - - $this->assertSame( 'error', $result['status'] ); - $this->assertSame( 'token_empty', $result['debug'] ); - } - - /** - * validate_credentials_detailed reports 'account_id_empty' when the - * account ID option is blank, without touching the network. - */ - public function test_validate_credentials_reports_missing_account_id(): void { - $this->stub_get_option( [ - 'prautoblogger_cloudflare_ai_token' => 'not-empty', - 'prautoblogger_cloudflare_account_id' => '', - 'prautoblogger_log_level' => 'info', - ] ); - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - $result = $provider->validate_credentials_detailed(); - - $this->assertSame( 'error', $result['status'] ); - $this->assertSame( 'account_id_empty', $result['debug'] ); - } - - /** - * validate_credentials_detailed must never invoke the generation endpoint. - * Asserted by tracking every wp_remote_post call: a real-image call is - * the only thing that POSTs to `/ai/run/...`. Validator uses GET on - * `/ai/models/search`, which is the free read-only path. - */ - public function test_validate_credentials_never_calls_generate_endpoint(): void { - $this->with_token( 'test-token' ); - - $post_calls = []; - Functions\when( 'wp_remote_post' )->alias( - function ( $url, $args ) use ( &$post_calls ) { - $post_calls[] = $url; - return [ 'body' => '', 'response' => [ 'code' => 500 ] ]; - } - ); - Functions\when( 'wp_remote_get' )->justReturn( [ - 'body' => '{"result":[]}', - 'response' => [ 'code' => 200 ], - ] ); - Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); - Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"result":[]}' ); - - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - $provider->validate_credentials_detailed(); - - $this->assertSame( [], $post_calls, 'validate_credentials_detailed() must not POST to /ai/run/ endpoints.' ); - } - - /** - * Retry path: a 500 followed by a 200 must yield the 200 payload. - * Exercises the exponential-backoff loop end-to-end without sleeping - * (bootstrap sets PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS to 0). - */ - public function test_generate_image_retries_on_5xx_then_succeeds(): void { - $this->with_token( 'test-token' ); - - $response_codes = [ 500, 200 ]; - $response_bodies = [ 'upstream hiccup', self::FAKE_IMAGE_BYTES ]; - - Functions\when( 'wp_remote_post' )->justReturn( [ - 'body' => self::FAKE_IMAGE_BYTES, - 'response' => [ 'code' => 200 ], - 'headers' => [ 'content-type' => 'image/png' ], - ] ); - Functions\when( 'wp_remote_retrieve_response_code' )->alias( - function () use ( &$response_codes ) { - return array_shift( $response_codes ) ?? 200; - } - ); - Functions\when( 'wp_remote_retrieve_body' )->alias( - function () use ( &$response_bodies ) { - return array_shift( $response_bodies ) ?? self::FAKE_IMAGE_BYTES; - } - ); - // wp_remote_retrieve_header is called only on the success attempt — - // the 5xx branch returns before any header lookup. One justReturn - // for the content-type is enough; retry-after lookups accept the - // same stub and will coerce to (int) 0. - Functions\when( 'wp_remote_retrieve_header' )->justReturn( 'image/png' ); - - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - $result = $provider->generate_image( 'a prompt', 1080, 1080 ); - - $this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] ); - } - - /** - * Retry exhaustion: persistent 502s must throw after MAX_RETRIES attempts - * rather than hanging indefinitely. - */ - public function test_generate_image_fails_after_max_retries_on_persistent_5xx(): void { - $this->with_token( 'test-token' ); - - $call_count = 0; - Functions\when( 'wp_remote_post' )->alias( - function () use ( &$call_count ) { - ++$call_count; - return [ - 'body' => 'bad gateway', - 'response' => [ 'code' => 502 ], - 'headers' => [], - ]; - } - ); - Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 502 ); - Functions\when( 'wp_remote_retrieve_body' )->justReturn( 'bad gateway' ); - - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - - try { - $provider->generate_image( 'a prompt', 1080, 1080 ); - $this->fail( 'Expected RuntimeException after retry exhaustion.' ); - } catch ( \RuntimeException $e ) { - $this->assertStringContainsString( '502', $e->getMessage() ); - } - $this->assertSame( - PRAUTOBLOGGER_MAX_RETRIES, - $call_count, - 'Provider must attempt exactly PRAUTOBLOGGER_MAX_RETRIES times before giving up.' - ); - } - - /** - * 429 triggers the same retry loop as 5xx (rate-limited, not deterministic - * error). The provider must retry and eventually succeed if the second - * attempt returns 200. - */ - public function test_generate_image_retries_on_429(): void { - $this->with_token( 'test-token' ); - - $response_codes = [ 429, 200 ]; - $response_bodies = [ 'slow down', self::FAKE_IMAGE_BYTES ]; - - Functions\when( 'wp_remote_post' )->justReturn( [ - 'body' => self::FAKE_IMAGE_BYTES, - 'response' => [ 'code' => 200 ], - 'headers' => [ 'content-type' => 'image/png' ], - ] ); - Functions\when( 'wp_remote_retrieve_response_code' )->alias( - function () use ( &$response_codes ) { - return array_shift( $response_codes ) ?? 200; - } - ); - Functions\when( 'wp_remote_retrieve_body' )->alias( - function () use ( &$response_bodies ) { - return array_shift( $response_bodies ) ?? self::FAKE_IMAGE_BYTES; - } - ); - // Content-type is only read on the success attempt; retry-after is - // read on the 429 attempt and (int)-coerces anything non-numeric - // back to 0, falling through to exponential backoff. - Functions\when( 'wp_remote_retrieve_header' )->justReturn( 'image/png' ); - - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - $result = $provider->generate_image( 'a prompt', 1080, 1080 ); - - $this->assertSame( self::FAKE_IMAGE_BYTES, $result['bytes'] ); - } - - /** - * validate_credentials_detailed returns 'ok' on a 200 from the models - * listing endpoint. - */ - public function test_validate_credentials_ok_on_200(): void { - $this->with_token( 'test-token' ); - Functions\when( 'wp_remote_get' )->justReturn( [ - 'body' => '{"result":[{"id":"@cf/black-forest-labs/flux-1-schnell"}]}', - 'response' => [ 'code' => 200 ], - ] ); - Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); - Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"result":[]}' ); - - $provider = new \PRAutoBlogger_Cloudflare_Image_Provider(); - $result = $provider->validate_credentials_detailed(); - - $this->assertSame( 'ok', $result['status'] ); - } -} diff --git a/tests/unit/Providers/CloudflareImageSupportTest.php b/tests/unit/Providers/CloudflareImageSupportTest.php deleted file mode 100644 index da160e2..0000000 --- a/tests/unit/Providers/CloudflareImageSupportTest.php +++ /dev/null @@ -1,115 +0,0 @@ -stub_get_option( [ - 'prautoblogger_ai_gateway_base_url' => '', - 'prautoblogger_cf_image_via_gateway' => '1', - ] ); - - $support = new \PRAutoBlogger_Cloudflare_Image_Support(); - $url = $support->build_endpoint_url( self::ACCOUNT_ID, self::MODEL ); - - $this->assertStringStartsWith( 'https://api.cloudflare.com/client/v4/accounts/', $url ); - $this->assertStringContainsString( self::ACCOUNT_ID, $url ); - $this->assertStringEndsWith( self::MODEL, $url ); - } - - /** - * With the AI Gateway URL configured and the toggle on, image calls - * route through `gateway.ai.cloudflare.com/.../workers-ai/{model}` at - * the same gateway root as the OpenRouter LLM path. - */ - public function test_returns_gateway_url_when_configured_and_toggle_on(): void { - $this->stub_get_option( [ - 'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/' . self::ACCOUNT_ID . '/peptiderepo-prod/openrouter', - 'prautoblogger_cf_image_via_gateway' => '1', - ] ); - - $support = new \PRAutoBlogger_Cloudflare_Image_Support(); - $url = $support->build_endpoint_url( self::ACCOUNT_ID, self::MODEL ); - - $this->assertSame( - 'https://gateway.ai.cloudflare.com/v1/' . self::ACCOUNT_ID . '/peptiderepo-prod/workers-ai/' . self::MODEL, - $url - ); - } - - /** - * The toggle overrides the gateway URL: operators can fall back to the - * direct API if the gateway regresses, without clearing the OpenRouter - * gateway setting that the LLM provider depends on. - */ - public function test_toggle_off_forces_direct_url_even_with_gateway_configured(): void { - $this->stub_get_option( [ - 'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/' . self::ACCOUNT_ID . '/peptiderepo-prod/openrouter', - 'prautoblogger_cf_image_via_gateway' => '0', - ] ); - - $support = new \PRAutoBlogger_Cloudflare_Image_Support(); - $url = $support->build_endpoint_url( self::ACCOUNT_ID, self::MODEL ); - - $this->assertStringStartsWith( 'https://api.cloudflare.com/client/v4/accounts/', $url ); - } - - /** - * A malformed gateway URL (e.g. http, or a totally different host) is - * rejected and the direct-API path is used instead. Defensive — a bad - * option value shouldn't break image generation. - */ - public function test_falls_back_to_direct_when_gateway_url_is_malformed(): void { - $this->stub_get_option( [ - 'prautoblogger_ai_gateway_base_url' => 'http://evil.example.com/openrouter', - 'prautoblogger_cf_image_via_gateway' => '1', - ] ); - - $support = new \PRAutoBlogger_Cloudflare_Image_Support(); - $url = $support->build_endpoint_url( self::ACCOUNT_ID, self::MODEL ); - - $this->assertStringStartsWith( 'https://api.cloudflare.com/client/v4/accounts/', $url ); - } - - /** - * The NSFW detector must match on the documented error code without - * tripping on other 4xx bodies. (Covered at the provider level too, - * but worth pinning at the support class so a tolerance regression - * fails early.) - */ - public function test_is_nsfw_error_matches_code_3030_only(): void { - $support = new \PRAutoBlogger_Cloudflare_Image_Support(); - - $this->assertTrue( - $support->is_nsfw_error( '{"errors":[{"code":3030,"message":"NSFW"}]}' ) - ); - $this->assertFalse( - $support->is_nsfw_error( '{"errors":[{"code":7003,"message":"bad token"}]}' ) - ); - $this->assertFalse( - $support->is_nsfw_error( 'not json at all' ) - ); - $this->assertFalse( - $support->is_nsfw_error( '{}' ) - ); - } -} From 21430ce3bfd0921935d9d9bf6b0ecdb24276921a Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Tue, 21 Apr 2026 16:48:04 +0000 Subject: [PATCH 2/6] cf-removal: patch registry, pipeline, settings, admin-page, interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires out the Cloudflare Workers AI image provider across the plugin: - class-image-pipeline.php: drop 'cloudflare' dispatch branch; Runware becomes the unrecognised-provider fallback - class-image-model-registry.php: remove flux-1-schnell/cloudflare row; update provider-enum docblocks (runware|openrouter only) - class-settings-fields-extended.php: remove three CF fields (cloudflare_ai_token, cloudflare_account_id, cf_image_via_gateway); drop Cloudflare mention from model-picker + NSFW retry descriptions - class-admin-page.php: remove prautoblogger_cloudflare_ai_token from the encrypted-options list - interface-image-provider.php: retarget preamble @see at Runware + OpenRouter; update get_provider_name() example - class-image-nsfw-blocked.php: generalise preamble (Runware + OpenRouter can raise this) Also cleans stale CF Workers AI mentions in: class-ajax-handlers.php, interface-model-registry.php, class-image-nsfw-retry.php, class-open-router-image-pricing.php, prautoblogger.php. The Cloudflare AI Gateway path (in class-open-router-config.php, class-settings-fields.php 'prautoblogger_ai_gateway_base_url', class-open-router-request-builder.php cache headers) is intentionally untouched — it fronts OpenRouter and is unrelated to the CF Workers AI image provider. --- includes/admin/class-admin-page.php | 2 +- includes/admin/class-image-model-registry.php | 12 ++------ .../admin/class-settings-fields-extended.php | 28 ++----------------- includes/class-ajax-handlers.php | 2 +- includes/core/class-image-nsfw-retry.php | 4 +-- includes/core/class-image-pipeline.php | 12 ++++---- .../providers/class-image-nsfw-blocked.php | 18 ++++++------ .../class-open-router-image-pricing.php | 4 +-- .../providers/interface-image-provider.php | 7 +++-- .../services/interface-model-registry.php | 8 +++--- prautoblogger.php | 2 +- 11 files changed, 35 insertions(+), 64 deletions(-) diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php index a0ed97f..e8cd9ac 100755 --- a/includes/admin/class-admin-page.php +++ b/includes/admin/class-admin-page.php @@ -231,7 +231,7 @@ class PRAutoBlogger_Admin_Page { $option_name = substr( $filter, strlen( 'sanitize_option_' ) ); } - $encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_cloudflare_ai_token', 'prautoblogger_runware_api_key' ]; + $encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' ]; if ( in_array( $option_name, $encrypted, true ) ) { // Empty value means password field wasn't touched — keep existing. if ( '' === $value ) { diff --git a/includes/admin/class-image-model-registry.php b/includes/admin/class-image-model-registry.php index 9fd35c6..1a38601 100644 --- a/includes/admin/class-image-model-registry.php +++ b/includes/admin/class-image-model-registry.php @@ -22,7 +22,7 @@ class PRAutoBlogger_Image_Model_Registry { * Each entry contains: * - id: Model identifier used in API calls. * - name: Human-readable display name. - * - provider: 'runware' | 'openrouter' | 'cloudflare'. + * - provider: 'runware' | 'openrouter'. * - cost_per_image: Estimated USD cost per generation. * - capabilities: Array of capability tags. * - description: Short description for the admin UI. @@ -90,14 +90,6 @@ class PRAutoBlogger_Image_Model_Registry { 'capabilities' => [ 'image_generation' ], 'description' => __( 'OpenAI premium. Photorealistic.', 'prautoblogger' ), ], - [ - 'id' => 'flux-1-schnell', - 'name' => 'FLUX.1 schnell (Cloudflare)', - 'provider' => 'cloudflare', - 'cost_per_image' => 0.0007, - 'capabilities' => [ 'image_generation' ], - 'description' => __( 'Legacy Cloudflare fallback. Use Runware FLUX instead.', 'prautoblogger' ), - ], ]; } @@ -106,7 +98,7 @@ class PRAutoBlogger_Image_Model_Registry { * model id is not in the registry. * * @param string $model_id Model slug from the admin UI. - * @return string Provider id ('runware' | 'openrouter' | 'cloudflare' | ''). + * @return string Provider id ('runware' | 'openrouter' | ''). */ public static function provider_for( string $model_id ): string { foreach ( self::get_models() as $model ) { diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index f4bfcd0..7093a2d 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -221,25 +221,9 @@ class PRAutoBlogger_Settings_Fields_Extended { 'section' => 'prautoblogger_images', 'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, 'capability' => 'image_generation', - 'description' => __( 'Pick an image model. The provider (OpenRouter or Cloudflare Workers AI) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ), + 'description' => __( 'Pick an image model. The provider (Runware or OpenRouter) is derived from the model registry on save, so mismatched pairs are no longer possible.', 'prautoblogger' ), 'badge' => __( 'Quality', 'prautoblogger' ), ], - [ - 'id' => 'prautoblogger_cloudflare_ai_token', - 'label' => __( 'Cloudflare API Token', 'prautoblogger' ), - 'type' => 'password', - 'section' => 'prautoblogger_images', - 'description' => __( 'Only needed if using Cloudflare provider. Workers AI token with Read + Edit scope.', 'prautoblogger' ), - 'icon' => '🔑', - ], - [ - 'id' => 'prautoblogger_cloudflare_account_id', - 'label' => __( 'Cloudflare Account ID', 'prautoblogger' ), - 'type' => 'text', - 'section' => 'prautoblogger_images', - 'default' => '', - 'description' => __( 'Only needed if using Cloudflare provider.', 'prautoblogger' ), - ], [ 'id' => 'prautoblogger_runware_api_key', 'label' => __( 'Runware API Key', 'prautoblogger' ), @@ -270,15 +254,7 @@ class PRAutoBlogger_Settings_Fields_Extended { 'type' => 'toggle', 'section' => 'prautoblogger_images', 'default' => '1', - 'description' => __( 'When the provider rejects an image prompt as NSFW (e.g. Cloudflare code 3030), retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ), - ], - [ - 'id' => 'prautoblogger_cf_image_via_gateway', - 'label' => __( 'Route Cloudflare Images via AI Gateway', 'prautoblogger' ), - 'type' => 'toggle', - 'section' => 'prautoblogger_images', - 'default' => '1', - 'description' => __( 'Route Cloudflare Workers AI image calls through the Cloudflare AI Gateway (requires the AI Gateway URL configured under API Keys). Adds response caching, cost logging, and rate-limiting. Turn off to hit the direct Workers AI API — only needed if the gateway route regresses.', 'prautoblogger' ), + 'description' => __( 'When the provider rejects an image prompt as NSFW, retry once with a generic fallback scene built from the article title. Disable to fail fast if the filter gets trigger-happy.', 'prautoblogger' ), ], ]; } diff --git a/includes/class-ajax-handlers.php b/includes/class-ajax-handlers.php index 63283cd..c8e48a2 100644 --- a/includes/class-ajax-handlers.php +++ b/includes/class-ajax-handlers.php @@ -42,7 +42,7 @@ class PRAutoBlogger_Ajax_Handlers { return; } - // Image generation can take 20-30s on Cloudflare Workers AI. + // Image generation can take 20-30s (Runware FLUX + OpenRouter image models). // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged @set_time_limit( 120 ); diff --git a/includes/core/class-image-nsfw-retry.php b/includes/core/class-image-nsfw-retry.php index c5c0ebb..8b0ed71 100644 --- a/includes/core/class-image-nsfw-retry.php +++ b/includes/core/class-image-nsfw-retry.php @@ -4,8 +4,8 @@ declare(strict_types=1); /** * Single-shot retry of NSFW-blocked image slots with a sanitized prompt. * - * When an image provider (currently only Cloudflare Workers AI) rejects a - * prompt via its content filter, its batch result carries + * When an image provider rejects a prompt via its content filter, its + * batch result carries * `['error_type' => 'nsfw_blocked']`. This helper reruns just the blocked * slot once with the rule-based fallback prompt (article title + style * suffix, no LLM rewrite) and merges the new result back into the batch. diff --git a/includes/core/class-image-pipeline.php b/includes/core/class-image-pipeline.php index 6d303fe..d5decc4 100644 --- a/includes/core/class-image-pipeline.php +++ b/includes/core/class-image-pipeline.php @@ -38,7 +38,7 @@ class PRAutoBlogger_Image_Pipeline { * * When no provider is injected, the constructor reads the * `prautoblogger_image_provider` setting and instantiates the - * matching concrete class ('runware', 'openrouter', or 'cloudflare'). + * matching concrete class ('runware' or 'openrouter'). * * @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider override. * @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker. @@ -56,17 +56,19 @@ class PRAutoBlogger_Image_Pipeline { /** * Instantiate the image provider based on the admin setting. * + * Returns Runware by default when the setting is unrecognised — + * Cloudflare Workers AI was removed as a provider in v0.10.0; legacy + * 'cloudflare' values are migrated at admin_init, but an unmigrated + * install should still get a working fallback rather than a fatal. + * * @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 ( 'runware' === $provider_id ) { - return new PRAutoBlogger_Runware_Image_Provider(); - } if ( 'openrouter' === $provider_id ) { return new PRAutoBlogger_OpenRouter_Image_Provider(); } - return new PRAutoBlogger_Cloudflare_Image_Provider(); + return new PRAutoBlogger_Runware_Image_Provider(); } /** diff --git a/includes/providers/class-image-nsfw-blocked.php b/includes/providers/class-image-nsfw-blocked.php index a2d18ee..1b50d78 100644 --- a/includes/providers/class-image-nsfw-blocked.php +++ b/includes/providers/class-image-nsfw-blocked.php @@ -4,18 +4,18 @@ declare(strict_types=1); /** * Exception raised when an image provider rejects a prompt as NSFW. * - * Cloudflare Workers AI returns HTTP 400 with an `errors[].code === 3030` - * body when its content filter blocks a prompt. We catch that shape in - * the provider and raise this typed exception so the image pipeline can - * distinguish a content-filter rejection from a generic 4xx and retry - * once with a sanitized fallback prompt. + * Image providers (Runware, OpenRouter) return provider-specific error + * shapes when their content filters block a prompt. We catch those shapes + * in each provider and raise this typed exception so the image pipeline + * can distinguish a content-filter rejection from a generic 4xx and + * retry once with a sanitized fallback prompt. * - * Triggered by: PRAutoBlogger_Cloudflare_Image_Provider on NSFW 4xx. + * Triggered by: image providers on NSFW-filter rejection. * Dependencies: None. * - * @see providers/class-cloudflare-image-provider.php — Raises this. - * @see core/class-image-nsfw-retry.php — Catches + rebuilds prompt. - * @see https://developers.cloudflare.com/workers-ai/models/flux-1-schnell/ — Upstream error codes. + * @see providers/class-runware-image-provider.php — May raise this. + * @see providers/class-open-router-image-provider.php — May raise this. + * @see core/class-image-nsfw-retry.php — Catches + rebuilds prompt. */ class PRAutoBlogger_Image_NSFW_Blocked extends \RuntimeException { diff --git a/includes/providers/class-open-router-image-pricing.php b/includes/providers/class-open-router-image-pricing.php index 978ebdb..9613744 100644 --- a/includes/providers/class-open-router-image-pricing.php +++ b/includes/providers/class-open-router-image-pricing.php @@ -4,7 +4,7 @@ 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 + * Mirrors the Runware 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() @@ -12,7 +12,7 @@ declare(strict_types=1); * Dependencies: None — pure calculation + option reads. * * @see class-openrouter-image-provider.php — Caller. - * @see class-cloudflare-image-pricing.php — Sibling pattern for Cloudflare. + * @see class-runware-image-pricing.php — Sibling pattern for Runware. */ class PRAutoBlogger_OpenRouter_Image_Pricing { diff --git a/includes/providers/interface-image-provider.php b/includes/providers/interface-image-provider.php index 4e8b804..7a6a8d3 100644 --- a/includes/providers/interface-image-provider.php +++ b/includes/providers/interface-image-provider.php @@ -2,7 +2,7 @@ declare(strict_types=1); /** - * Contract for any image generation provider (Cloudflare Workers AI, DALL-E, etc.). + * Contract for any image generation provider (Runware FLUX, OpenRouter Gemini, etc.). * * Consumers depend on this interface, never on a concrete provider — swapping * providers means writing one new class, not refactoring the pipeline. This @@ -13,7 +13,8 @@ declare(strict_types=1); * content generation run, after editorial review, before publish. * Dependencies: None at interface level — implementations wrap a specific HTTP API. * - * @see class-cloudflare-image-provider.php — Cloudflare Workers AI implementation. + * @see class-runware-image-provider.php — Runware FLUX implementation (default). + * @see class-open-router-image-provider.php — OpenRouter image implementation. * @see interface-llm-provider.php — Parallel pattern for LLM providers. * @see ARCHITECTURE.md — External API Integrations table. */ @@ -79,7 +80,7 @@ interface PRAutoBlogger_Image_Provider_Interface { /** * Machine-readable provider identifier used in logs and settings. * - * @return string Lowercase identifier, e.g. 'cloudflare_workers_ai'. + * @return string Lowercase identifier, e.g. 'runware' or 'openrouter'. */ public function get_provider_name(): string; diff --git a/includes/services/interface-model-registry.php b/includes/services/interface-model-registry.php index 3ca9606..6fe192e 100644 --- a/includes/services/interface-model-registry.php +++ b/includes/services/interface-model-registry.php @@ -5,9 +5,9 @@ declare(strict_types=1); * Contract for a provider-specific AI model registry. * * Phase 1 (v1): single implementation for OpenRouter (`class-open-router-model-registry.php`). - * Phase 3: a second implementation wraps Cloudflare Workers AI model data. A - * higher-level dispatcher iterates registered registries, calls each with the same - * capability, and unions the results — the per-registry interface stays unchanged. + * Phase 3: a second implementation wraps Runware model data. A higher-level + * dispatcher iterates registered registries, calls each with the same capability, + * and unions the results — the per-registry interface stays unchanged. * * Implementations MUST: * - Store the normalized registry as a WP option + transient cache layer. @@ -86,7 +86,7 @@ interface PRAutoBlogger_Model_Registry_Interface { public function get_fetched_at(): int; /** - * Machine-readable provider identifier (e.g. 'openrouter', 'cloudflare_workers_ai'). + * Machine-readable provider identifier (e.g. 'openrouter', 'runware'). * * @return string */ diff --git a/prautoblogger.php b/prautoblogger.php index e07fa32..cd6d609 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -54,7 +54,7 @@ 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. -// Default provider is Runware; OpenRouter (Gemini) + Cloudflare remain as alternates. +// Default provider is Runware; OpenRouter (Gemini) remains as the alternate. // Default model is FLUX.1 schnell via Runware (~$0.0006/image, ~2s). CEO // decision 2026-04-21 after comic A/B: schnell's looser fidelity reads as // editorial-cartoon style and is 65× cheaper than Nano Banana. Users can From 97129ed457e03bbbaa7a0b91447690b76ce9c165 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Tue, 21 Apr 2026 16:52:17 +0000 Subject: [PATCH 3/6] cf-removal: add v0.10.0 migration + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New class PRAutoBlogger_Migrate_Remove_Cloudflare_V0100 (own file so class-activator.php stays under 300 lines). Runs from PRAutoBlogger::maybe_run_db_migrations() right after the v0.9.0 migration. Idempotent via prautoblogger_migrated_remove_cloudflare_v0100 flag option (picked up by the uninstall prefix sweep). Migrates any user on a Cloudflare image provider to Runware FLUX.1 schnell (closest equivalent, ~bash.0006/image). Detects the three shapes a legacy user can be in: registry-derived provider === 'cloudflare', image_provider option === 'cloudflare', or the legacy slug 'flux-1-schnell'. Cleans up three now-dead options: prautoblogger_cloudflare_ai_token, prautoblogger_cloudflare_account_id, prautoblogger_cf_image_via_gateway. Does NOT touch prautoblogger_ai_gateway_base_url — that option is shared with the OpenRouter AI Gateway route and must stay (live- verified test). Tests (tests/unit/Core/ActivatorRemoveCloudflareTest.php): - cloudflare user → runware schnell - non-cloudflare user preserved - runware user preserved - legacy 'flux-1-schnell' slug triggers migration - second run is a no-op (does not clobber admin manual changes) - AI Gateway URL preserved across every path ImageModelRegistryTest updated: allowed-provider set is now ['runware','openrouter']; new test_cloudflare_entries_removed_v0100 test pins the scope boundary. --- .../class-migrate-remove-cloudflare-v0100.php | 99 +++++++++++ includes/class-prautoblogger.php | 4 +- includes/core/class-image-pipeline.php | 8 +- .../Core/ActivatorRemoveCloudflareTest.php | 163 ++++++++++++++++++ .../unit/Services/ImageModelRegistryTest.php | 29 +++- 5 files changed, 289 insertions(+), 14 deletions(-) create mode 100644 includes/class-migrate-remove-cloudflare-v0100.php create mode 100644 tests/unit/Core/ActivatorRemoveCloudflareTest.php diff --git a/includes/class-migrate-remove-cloudflare-v0100.php b/includes/class-migrate-remove-cloudflare-v0100.php new file mode 100644 index 0000000..f864eb1 --- /dev/null +++ b/includes/class-migrate-remove-cloudflare-v0100.php @@ -0,0 +1,99 @@ + */ + private array $options = []; + + protected function setUp(): void { + parent::setUp(); + + $this->options = []; + + Functions\when( 'get_option' )->alias( + fn( $key, $default = false ) => $this->options[ $key ] ?? $default + ); + Functions\when( 'update_option' )->alias( + function ( $key, $value ) { + $this->options[ $key ] = $value; + return true; + } + ); + Functions\when( 'delete_option' )->alias( + function ( $key ) { + unset( $this->options[ $key ] ); + return true; + } + ); + } + + /** + * Cloudflare user: flipped to Runware schnell, CF credentials removed, + * gateway URL preserved, flag set. + */ + public function test_cloudflare_user_migrated_to_runware(): void { + $this->options = [ + 'prautoblogger_image_provider' => 'cloudflare', + 'prautoblogger_image_model' => 'flux-1-schnell', + 'prautoblogger_cloudflare_ai_token' => 'enc:SECRET', + 'prautoblogger_cloudflare_account_id' => 'acct-123', + 'prautoblogger_cf_image_via_gateway' => '1', + // Shared with OpenRouter AI Gateway — MUST be preserved. + 'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter', + ]; + + \PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run(); + + $this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] ); + $this->assertSame( 'runware:100@1', $this->options['prautoblogger_image_model'] ); + + $this->assertArrayNotHasKey( 'prautoblogger_cloudflare_ai_token', $this->options ); + $this->assertArrayNotHasKey( 'prautoblogger_cloudflare_account_id', $this->options ); + $this->assertArrayNotHasKey( 'prautoblogger_cf_image_via_gateway', $this->options ); + + $this->assertSame( + 'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter', + $this->options['prautoblogger_ai_gateway_base_url'], + 'AI Gateway URL (shared with OpenRouter route) must NOT be touched.' + ); + + $this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] ); + } + + /** + * Non-Cloudflare user (e.g. already on OpenRouter Gemini): model + + * provider untouched; CF credentials still swept; flag set. + */ + public function test_non_cloudflare_user_preserved(): void { + $this->options = [ + 'prautoblogger_image_provider' => 'openrouter', + 'prautoblogger_image_model' => 'google/gemini-2.5-flash-image', + // Hypothetical orphans from an earlier experiment. + 'prautoblogger_cloudflare_ai_token' => 'enc:OLD', + 'prautoblogger_cloudflare_account_id' => 'acct-999', + 'prautoblogger_ai_gateway_base_url' => 'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter', + ]; + + \PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run(); + + $this->assertSame( 'openrouter', $this->options['prautoblogger_image_provider'] ); + $this->assertSame( 'google/gemini-2.5-flash-image', $this->options['prautoblogger_image_model'] ); + + $this->assertArrayNotHasKey( 'prautoblogger_cloudflare_ai_token', $this->options ); + $this->assertArrayNotHasKey( 'prautoblogger_cloudflare_account_id', $this->options ); + $this->assertSame( + 'https://gateway.ai.cloudflare.com/v1/acct/gw/openrouter', + $this->options['prautoblogger_ai_gateway_base_url'] + ); + $this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] ); + } + + /** + * Runware user: provider + model untouched; flag set. + */ + public function test_runware_user_preserved(): void { + $this->options = [ + 'prautoblogger_image_provider' => 'runware', + 'prautoblogger_image_model' => 'runware:101@1', + ]; + + \PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run(); + + $this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] ); + $this->assertSame( 'runware:101@1', $this->options['prautoblogger_image_model'] ); + $this->assertSame( '1', $this->options['prautoblogger_migrated_remove_cloudflare_v0100'] ); + } + + /** + * Legacy slug detection — the pre-registry `flux-1-schnell` model id + * still gets migrated even when the provider option is empty. + */ + public function test_legacy_slug_triggers_migration(): void { + $this->options = [ + 'prautoblogger_image_provider' => '', // Unset before v0.9.0 landed. + 'prautoblogger_image_model' => 'flux-1-schnell', + ]; + + \PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run(); + + $this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] ); + $this->assertSame( 'runware:100@1', $this->options['prautoblogger_image_model'] ); + } + + /** + * Running the migration twice is idempotent — the second pass must + * return immediately and must not clobber a user change made after + * the first migration run. + */ + public function test_idempotent_second_run_is_noop(): void { + $this->options = [ + 'prautoblogger_image_provider' => 'cloudflare', + 'prautoblogger_image_model' => 'flux-1-schnell', + ]; + + \PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run(); + $this->assertSame( 'runware', $this->options['prautoblogger_image_provider'] ); + + // Simulate admin manually switching to OpenRouter after migration. + $this->options['prautoblogger_image_provider'] = 'openrouter'; + $this->options['prautoblogger_image_model'] = 'google/gemini-3.1-flash-image-preview'; + + \PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run(); + + // Second run must NOT overwrite the admin's manual choice. + $this->assertSame( 'openrouter', $this->options['prautoblogger_image_provider'] ); + $this->assertSame( 'google/gemini-3.1-flash-image-preview', $this->options['prautoblogger_image_model'] ); + } +} diff --git a/tests/unit/Services/ImageModelRegistryTest.php b/tests/unit/Services/ImageModelRegistryTest.php index b35ccce..73590b9 100644 --- a/tests/unit/Services/ImageModelRegistryTest.php +++ b/tests/unit/Services/ImageModelRegistryTest.php @@ -18,7 +18,8 @@ class ImageModelRegistryTest extends BaseTestCase { /** * Every entry returned by get_models() must carry a non-empty id and - * a provider of 'runware', 'openrouter', or 'cloudflare'. If this breaks, the + * a provider of 'runware' or 'openrouter' (Cloudflare Workers AI was + * removed as an image provider in v0.10.0). If this breaks, the * admin save flow will silently drop the provider derivation. */ public function test_every_registry_entry_has_id_and_known_provider(): void { @@ -31,7 +32,7 @@ class ImageModelRegistryTest extends BaseTestCase { $this->assertArrayHasKey( 'provider', $model ); $this->assertContains( $model['provider'], - [ 'runware', 'openrouter', 'cloudflare' ], + [ 'runware', 'openrouter' ], sprintf( 'Unexpected provider %s for model %s', (string) $model['provider'], (string) $model['id'] ) ); } @@ -42,14 +43,30 @@ class ImageModelRegistryTest extends BaseTestCase { * This is the core of the v0.8.0 collapsed-dropdown contract. */ public function test_provider_for_returns_paired_provider_for_known_model(): void { - $this->assertSame( - 'cloudflare', - \PRAutoBlogger_Image_Model_Registry::provider_for( 'flux-1-schnell' ) - ); $this->assertSame( 'openrouter', \PRAutoBlogger_Image_Model_Registry::provider_for( 'google/gemini-2.5-flash-image' ) ); + $this->assertSame( + 'runware', + \PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:100@1' ) + ); + } + + /** + * v0.10.0 scope boundary: no Cloudflare entries remain in the registry. + * The legacy `flux-1-schnell` slug (pre-Runware) must resolve to '' so + * the migration's detection path catches it. If a registry regression + * re-adds a cloudflare entry this test fails loudly. + */ + public function test_cloudflare_entries_removed_v0100(): void { + foreach ( \PRAutoBlogger_Image_Model_Registry::get_models() as $model ) { + $this->assertNotSame( 'cloudflare', $model['provider'] ?? '' ); + } + $this->assertSame( + '', + \PRAutoBlogger_Image_Model_Registry::provider_for( 'flux-1-schnell' ) + ); } /** From ef85100501f0dd3fd288083db80cd92aa5849a30 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Tue, 21 Apr 2026 16:53:33 +0000 Subject: [PATCH 4/6] cf-removal: bump 0.9.0 -> 0.10.0 + update docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prautoblogger.php: plugin header Version + PRAUTOBLOGGER_VERSION - CHANGELOG.md: Removed/Changed/Note/Cost impact/Migration sections - ARCHITECTURE.md: - drop 4 CF provider files from the file tree - drop 2 CF credential options from the wp_options table - drop CF Workers AI row from the External API Integrations table - flip Phase 3 placeholder from CF Workers AI registry to Runware - supersede ADR #16 with a one-line history note pointing at v0.10.0 - update ADR #17 tail to mention the removal readme.txt stable tag is stale (0.2.2) and clearly not maintained inline with real releases — leaving alone in this PR; fix belongs in a dedicated release-process cleanup. --- ARCHITECTURE.md | 16 ++++-------- CHANGELOG.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++ prautoblogger.php | 4 +-- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cfcd857..51a1965 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -175,17 +175,13 @@ prautoblogger/ │ │ ├── class-runware-image-pricing.php # FLUX schnell/dev cost table + resolver │ │ ├── class-runware-image-support.php # Key, response parsing, retry, dimension snap │ │ ├── class-runware-image-batch.php # True parallel curl_multi batch dispatcher -│ │ ├── class-cloudflare-image-provider.php # Cloudflare Workers AI (sequential batch fallback) -│ │ ├── class-cloudflare-image-pricing.php # Model alias + per-MP cost estimation -│ │ ├── class-cloudflare-image-validator.php # Non-destructive credential + connectivity check -│ │ ├── class-cloudflare-image-support.php # Token, account, URL, and log helpers for CF provider │ │ └── (new providers go here — see CONVENTIONS.md) │ │ │ ├── services/ │ │ ├── interface-model-registry.php # Contract for provider-specific model registries │ │ ├── class-open-router-model-registry.php # Fetches, caches, queries the OpenRouter model list │ │ ├── class-open-router-model-normalizer.php # Maps raw OpenRouter data → standardized record shape -│ │ └── (Phase 3: class-cloudflare-workers-ai-model-registry.php) +│ │ └── (Phase 3: class-runware-model-registry.php) │ │ │ ├── frontend/ │ │ ├── class-article-typography.php # Inline CSS for font, size, table borders on generated posts @@ -439,8 +435,6 @@ All prefixed with `prautoblogger_`: | `prautoblogger_log_level` | Logging threshold: error/warning/info/debug | | `prautoblogger_db_version` | Schema version for migrations | | `prautoblogger_schedule_time` | Daily generation time (HH:MM, default: '03:00') | -| `prautoblogger_cloudflare_ai_token` | Encrypted Cloudflare Workers AI API token | -| `prautoblogger_cloudflare_account_id` | Cloudflare account UUID (plaintext — identifier, not secret) | | `prautoblogger_image_model` | Image model slug from `Image_Model_Registry::get_models()`; provider is derived from this on save | | `prautoblogger_image_provider` | Derived from the chosen model on save (v0.8.0+); not editable in admin UI | | `prautoblogger_image_prompt_instructions` | System prompt given to the image rewriter LLM (v0.8.0+); falls back to `Image_Prompt_Builder::REWRITER_SYSTEM_PROMPT` when empty | @@ -482,7 +476,6 @@ Stored on every PRAutoBlogger-generated post: | Reddit RSS | Primary Reddit data source — Atom feeds for subreddit hot posts | None (unauthenticated) | No known rate limit; reliable from datacenter IPs | `providers/class-reddit-json-client.php` | | Reddit .json | Fallback for posts + only source for comments | None (unauthenticated) | ~10 req/min (datacenter IPs often blocked) | `providers/class-reddit-json-client.php` | | Google Analytics 4 | Post performance metrics | OAuth2 service account | Standard GA4 limits | `core/class-ga4-client.php`, `core/class-metrics-collector.php` | -| Cloudflare Workers AI | Image generation (FLUX.1 schnell / dev) for article hero, thumbnail, and IG placements | API token (encrypted in wp_options) + account ID | Workers AI per-account quotas | `providers/class-cloudflare-image-provider.php`, `providers/class-cloudflare-image-pricing.php`, `providers/class-cloudflare-image-validator.php` | | Runware (FLUX.1 via runware.ai) | **Default** image backend (v0.9.0+): schnell ~$0.0006/image, dev ~$0.02/image. True parallel generation via curl_multi. | API key (encrypted in wp_options) | Account-level quotas | `providers/class-runware-image-provider.php`, `providers/class-runware-image-pricing.php`, `providers/class-runware-image-support.php`, `providers/class-runware-image-batch.php` | --- @@ -540,10 +533,11 @@ The admin model picker (v1) needs to list OpenRouter models with pricing and cap ### #17: Runware as default image backend (v0.9.0, Apr 2026) -After a comic-style A/B round in Apr 2026, FLUX.1 schnell via Runware was chosen as the default image backend. Cost is ~$0.0006/image vs ~$0.039/image for Gemini 2.5 Flash Image via OpenRouter (≈65× cheaper), and the looser schnell fidelity reads as editorial-cartoon style — a feature, not a bug, for our single-panel comic aesthetic. FLUX.1 dev (~$0.02/image, 28 steps) remains available as an opt-in for higher-fidelity runs. The Runware layer mirrors the OpenRouter split (interface + provider + support + pricing + batch), all under the 300-line cap. The batch class dispatches `imageInference` POSTs via `curl_multi_init/exec/select` — wall-clock time for the A/B pair drops to ≈ the slowest single image. A v0.9.0 one-time migration (`prautoblogger_migrated_default_image_v090`) flips sites still on the legacy default; explicit user selections are preserved. Cloudflare Workers AI remains as a legacy fallback in the registry. +After a comic-style A/B round in Apr 2026, FLUX.1 schnell via Runware was chosen as the default image backend. Cost is ~$0.0006/image vs ~$0.039/image for Gemini 2.5 Flash Image via OpenRouter (≈65× cheaper), and the looser schnell fidelity reads as editorial-cartoon style — a feature, not a bug, for our single-panel comic aesthetic. FLUX.1 dev (~$0.02/image, 28 steps) remains available as an opt-in for higher-fidelity runs. The Runware layer mirrors the OpenRouter split (interface + provider + support + pricing + batch), all under the 300-line cap. The batch class dispatches `imageInference` POSTs via `curl_multi_init/exec/select` — wall-clock time for the A/B pair drops to ≈ the slowest single image. A v0.9.0 one-time migration (`prautoblogger_migrated_default_image_v090`) flips sites still on the legacy default; explicit user selections are preserved. Cloudflare Workers AI was removed as an image provider in v0.10.0 (see ADR #16 note below). + +### #16: Image generation via Cloudflare Workers AI (FLUX.1) — SUPERSEDED v0.10.0 +Kept here for history. Until v0.8.2 the default image backend was FLUX.1 schnell on Cloudflare Workers AI, routed via the Cloudflare AI Gateway for caching + unified telemetry. In v0.9.0 (2026-04-21) Runware FLUX.1 schnell became the default (65× cheaper, see ADR #17). In v0.10.0 (2026-04-21) the Cloudflare Workers AI provider was removed entirely — classes, settings, tests, and registry entries are gone; any CF user is migrated to `runware:100@1` on upgrade via `PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run()`. The Cloudflare AI Gateway in front of OpenRouter (ADR #15) is unaffected — it is a distinct integration. -### #16: Image generation via Cloudflare Workers AI (FLUX.1), via AI Gateway (v0.8.2+) -The image-generation layer uses FLUX.1 [schnell] on Cloudflare Workers AI as its default model, chosen for its ~$0.0011/MP cost and 2–3 sec latency. FLUX.1 [dev] is a ~4× cost upgrade exposed as a dropdown for specific posts that warrant it. **v0.8.2+**: calls route through the Cloudflare AI Gateway (same gateway we already use for OpenRouter — `gateway.ai.cloudflare.com/v1/{account}/{gateway}/workers-ai/@cf/black-forest-labs/...`) for response caching, unified cost/latency telemetry, and rate limiting. The 2026-04-15 regression that caused the gateway route to 403 on Workers AI has been resolved upstream; verified by direct probe on 2026-04-21. A `prautoblogger_cf_image_via_gateway` toggle (default on) falls back to the direct `api.cloudflare.com/client/v4/accounts/{id}/ai/run/...` URL if the gateway regresses again. The provider lives behind `PRAutoBlogger_Image_Provider_Interface`, so switching to a different image API (DALL-E, Replicate) is a new class implementation plus a one-line swap in the pipeline wiring. ### #15: Optional Cloudflare AI Gateway in front of OpenRouter We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (`prautoblogger_ai_gateway_base_url`) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (`usage`, `choices[0].message.content`) is unchanged. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d61f6a..81e35cc 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,69 @@ All notable changes to PRAutoBlogger will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project uses [Semantic Versioning](https://semver.org/). +## [0.10.0] — 2026-04-21 + +### Removed + +- **Cloudflare Workers AI as image provider.** The four `class-cloudflare-image-*` + provider files are gone along with their unit tests and the three + CF-specific options (`prautoblogger_cloudflare_ai_token`, + `prautoblogger_cloudflare_account_id`, `prautoblogger_cf_image_via_gateway`). + Runware FLUX.1 schnell produces equivalent-quality output at ~65× + less cost and eliminates a duplicated settings surface. The + `flux-1-schnell` registry entry that mapped to the CF provider is + removed; the legacy slug now resolves to an empty provider (caught + by the migration). + +### Changed + +- **Sites on any `cloudflare` image provider auto-migrate to + `runware:100@1` on upgrade.** New migration + `PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run()` runs from + `admin_init` once per site. Detects three legacy shapes: registry- + derived provider `cloudflare`, `prautoblogger_image_provider` option + set to `cloudflare`, or the legacy slug `flux-1-schnell`. Sets + `prautoblogger_image_provider` + `prautoblogger_image_model` to the + Runware schnell pair and deletes the three dead CF options. Idempotent + via `prautoblogger_migrated_remove_cloudflare_v0100` flag — running + twice is a no-op and will not clobber a post-migration manual choice. +- **Image provider dispatch fallback is now Runware, not Cloudflare.** + `PRAutoBlogger_Image_Pipeline::create_default_provider()` returns + `PRAutoBlogger_Runware_Image_Provider` for any unrecognised + `prautoblogger_image_provider` value. An unmigrated install still + gets a working pipeline instead of a fatal. + +### Note + +- **Cloudflare AI Gateway fronting OpenRouter is unaffected.** The + `prautoblogger_ai_gateway_base_url` + `prautoblogger_ai_gateway_cache_ttl` + options, their settings fields, `class-open-router-config.php` + gateway resolution, and `class-open-router-request-builder.php` + `cf-aig-cache-ttl` headers are all intentionally untouched. The AI + Gateway is a proxy/cache layer in front of OpenRouter and is + orthogonal to the CF Workers AI image provider removal. The + migration test asserts this invariant explicitly. + +### Cost impact + +Zero recurring spend from CF Workers AI image generation (the +peptiderepo.com install was still on CF as recently as v0.8.2 — see +commit `80a7e11`). No new prompt tokens, no change in model mix for +sites already on Runware or OpenRouter. + +### Migration checklist (for anyone else running this plugin) + +1. On first admin_init after upgrade, the migration will flip + `prautoblogger_image_provider` to `runware` and + `prautoblogger_image_model` to `runware:100@1` if you were on CF. +2. **Set `prautoblogger_runware_api_key`** in Settings → Images if you + hadn't already — image generation will fail until it is set. +3. The CF API token + account ID options are deleted by the migration; + you don't need to do anything. +4. If you want to switch back to OpenRouter (Gemini) instead of + Runware, just change the Image Model picker after the migration + has run. + ## [0.9.0] — 2026-04-21 ### Added diff --git a/prautoblogger.php b/prautoblogger.php index cd6d609..0557da1 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -8,7 +8,7 @@ declare(strict_types=1); * Plugin Name: PRAutoBlogger * Plugin URI: https://peptiderepo.com/prautoblogger * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. - * Version: 0.9.0 + * Version: 0.10.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) { | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.9.0' ); +define( 'PRAUTOBLOGGER_VERSION', '0.10.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); From 382f2a0a6f9163a6cfaf220b60fa8752e8c74606 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Tue, 21 Apr 2026 16:56:30 +0000 Subject: [PATCH 5/6] cf-removal: drop prautoblogger_cf_image_via_gateway from fresh-activation defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The set_default_options() block was still seeding the dead CF Workers AI gateway toggle on fresh activations. Removed alongside its settings field + migration delete-sweep so uninstall → install cycles stop re-adding the option. Residual from commit 21430ce. Caught in local verification before PR open. --- includes/class-activator.php | 1 - 1 file changed, 1 deletion(-) diff --git a/includes/class-activator.php b/includes/class-activator.php index 61d470e..9594f82 100755 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -69,7 +69,6 @@ class PRAutoBlogger_Activator { 'prautoblogger_schedule_time' => '03:00', 'prautoblogger_log_level' => 'info', 'prautoblogger_image_nsfw_retry' => '1', - 'prautoblogger_cf_image_via_gateway' => '1', ]; foreach ( $defaults as $key => $value ) { From b3990232913a32072c0f7997a05e386a3bbb2d46 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Tue, 21 Apr 2026 17:01:54 +0000 Subject: [PATCH 6/6] cf-removal: move PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL define to test bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On v0.9.0 this constant was implicitly defined by CloudflareImageProviderTest::setUp() — deleted in this PR. OpenRouterImagePricingTest relied on PHPUnit's alphabetical file order (C before O) to have the constant defined before its assertion. Deleting the CF test broke that chain; OR pricing test errored with 'Undefined constant "PRAutoBlogger\\Tests\\Providers\\PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL"'. Fix: hoist the define into tests/bootstrap.php alongside the other PRAUTOBLOGGER_DEFAULT_* constants. No test ordering dependency remains. --- tests/bootstrap.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6ff6748..6d716a8 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -55,6 +55,15 @@ if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL' ) ) { define( 'PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL', 'google/gemini-2.5-flash-lite' ); } +// Default image model slug used by OpenRouter/Runware pricing fallbacks and +// settings UI. Source of truth: prautoblogger.php line 63. +// Historically this was defined implicitly by CloudflareImageProviderTest::setUp() +// — that test was deleted in v0.10.0 (cloudflare-workers-ai-removal), so the +// define now lives here where it belongs. +if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) { + define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' ); +} + // Default image style suffix used by ImagePromptBuilder and settings UI. if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX' ) ) { define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX', 'Style: test infomercial style.' );