Commit 1a: Cloudflare Workers AI image provider + pricing + validator (#9)

Implements image-provider interface + Cloudflare Workers AI adapter with pricing and validator. Independent QA verdict: APPROVE on 52fd11e (qa-reviews/prautoblogger/2026-04-15-52fd11e2ce97dd9e659df5cc87ec1382a668bec1.md). All 4 CTO blockers resolved (PHPUnit retry mocks, XSS hardening on error strings, PHPCS on new files, 300-line cap via support-class extraction). PHPCS backlog (586 errors across 38 pre-existing files) remains on continue-on-error; Workstream C material.
This commit is contained in:
peptiderepo
2026-04-15 18:25:46 +07:00
committed by GitHub
parent 2d75051967
commit 8e6b2d528f
12 changed files with 1231 additions and 1 deletions

View File

@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
/**
* Cloudflare Workers AI image provider — FLUX.1 family.
*
* Calls `https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/{model}`
* directly. We bypass the Cloudflare AI Gateway for v1 because the gateway
* route to Workers AI currently 403s (see decision D-001); the OpenRouter
* gateway path is unaffected.
*
* Triggered by: PRAutoBlogger_Image_Pipeline (commit 1b) during the content
* generation run, after editorial review, before publish. Also
* triggered by the admin "Test Connection" action.
* Dependencies: PRAutoBlogger_Encryption (token decryption), PRAutoBlogger_Logger
* (WARN/ERROR lines), PRAutoBlogger_Cloudflare_Image_Pricing
* (model resolution + cost estimation).
*
* @see interface-image-provider.php Interface this class implements.
* @see class-cloudflare-image-pricing.php Cost + model id helpers.
* @see class-cloudflare-image-support.php Token, account, URL, and log helpers.
* @see class-cloudflare-image-validator.php Credential check helper.
* @see class-open-router-provider.php Sibling provider; same retry pattern.
* @see ARCHITECTURE.md External API Integrations, key decision #16.
*/
class PRAutoBlogger_Cloudflare_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
private const PROVIDER_ID = 'cloudflare_workers_ai';
private const MIN_DIMENSION_PX = 64;
private const MAX_DIMENSION_PX = 2048;
private const DEFAULT_STEPS_SCHNELL = 4;
private const RETRY_AFTER_CAP_S = 60;
/**
* Pricing + model-id resolver. Constructed once per provider instance.
*
* @var PRAutoBlogger_Cloudflare_Image_Pricing
*/
private PRAutoBlogger_Cloudflare_Image_Pricing $pricing;
/**
* Token, account, URL, and log helpers — pulled out of this class to
* keep it under the 300-line cap.
*
* @var PRAutoBlogger_Cloudflare_Image_Support
*/
private PRAutoBlogger_Cloudflare_Image_Support $support;
/**
* Construct the provider with its default pricing + support helpers.
*/
public function __construct() {
$this->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 {
$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 );
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 )
)
);
}
/**
* 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 );
}
}
}