First commit of the image + Instagram A/B pipeline workstream. Ships the Cloudflare Workers AI image provider, its pricing + validator helpers, four new settings fields in a new "Images" admin section, and unit tests with mocked HTTP. Nothing calls the provider yet from the article pipeline — integration lands in commit 1b. Why: CEO-approved on 2026-04-15 (see convo/prautoblogger/threads/2026-04-image-pipeline/01-cto-handoff.md). Style is locked to the 90s infomercial pastiche; default model is FLUX.1 [schnell] at ~$0.0011/MP (~$0.01/article for 8 images across 4 placements x 2 variants). Risk flags: - Direct call to Cloudflare Workers AI bypassing AI Gateway (decision D-001, documented in convo/prautoblogger/decisions/). Gateway route to Workers AI currently 403s; OpenRouter gateway path unaffected. - prab_event_log structured writes deferred to commit 1b when the table lands (decision D-002). Logger-based logging is in place now. - No schema changes. No existing code paths touched outside settings. - Backward compatible: provider is instantiable but not wired to any consumer yet. Test plan: - CI runs PHPUnit including the new CloudflareImageProviderTest. - After merge: verify the new Images section appears on the settings page, the style-suffix textarea pre-fills with the infomercial prompt, and entering the existing CLOUDFLARE_AI_TOKEN + account UUID lets a future "Test Connection" button return OK (button ships in a later commit; the underlying validate_credentials_detailed() works today). Files: - includes/providers/interface-image-provider.php (adopted from CTO draft) - includes/providers/class-cloudflare-image-provider.php (283 lines) - includes/providers/class-cloudflare-image-pricing.php (78 lines) - includes/providers/class-cloudflare-image-validator.php (107 lines) - includes/admin/class-settings-fields.php (+ Images section, 4 fields) - prautoblogger.php (+ PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL, _STYLE_SUFFIX) - tests/unit/Providers/CloudflareImageProviderTest.php - ARCHITECTURE.md (file tree, integrations table, options table, decision #16) - CONVENTIONS.md (How To: Add a New Image Provider) - CHANGELOG.md (Unreleased section) Agent-Session: cowork-2026-04-15-image-pipeline-commit-1a
108 lines
3.4 KiB
PHP
108 lines
3.4 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* Credential + connectivity validator for the Cloudflare image provider.
|
|
*
|
|
* Split out of the provider to keep each file under the 300-line cap and
|
|
* to keep the validation path easy to unit-test without spinning up the
|
|
* retry loop. The provider's `validate_credentials_detailed()` delegates
|
|
* straight to `run()` here.
|
|
*
|
|
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider::validate_credentials_detailed()
|
|
* which is called by the admin "Test Connection" action.
|
|
* Dependencies: PRAutoBlogger_Encryption (token decryption), PRAutoBlogger_Logger
|
|
* (WARN/ERROR lines), wp_remote_get().
|
|
*
|
|
* @see class-cloudflare-image-provider.php Parent class that delegates here.
|
|
* @see interface-image-provider.php Return-shape contract.
|
|
*/
|
|
class PRAutoBlogger_Cloudflare_Image_Validator {
|
|
|
|
private const TIMEOUT_SECONDS = 15;
|
|
|
|
/**
|
|
* Run a non-destructive credential check.
|
|
*
|
|
* Lightweight probe: hits the account-scoped AI models listing endpoint.
|
|
* Never generates a real image — that would cost money on every click.
|
|
*
|
|
* Side effects: one HTTP GET; one Logger line on failure.
|
|
*
|
|
* @return array{status: string, message: string, debug?: string}
|
|
*/
|
|
public function run(): array {
|
|
$account_id = trim( (string) get_option( 'prautoblogger_cloudflare_account_id', '' ) );
|
|
if ( '' === $account_id ) {
|
|
return $this->err(
|
|
'account_id_empty',
|
|
__( 'Cloudflare account ID is not set. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
|
|
);
|
|
}
|
|
|
|
$encrypted = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
|
|
$api_token = '' === $encrypted ? '' : (string) PRAutoBlogger_Encryption::decrypt( $encrypted );
|
|
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( __( 'Network error reaching Cloudflare: %s', 'prautoblogger' ), $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( __( 'Cloudflare returned HTTP %1$d. %2$s', 'prautoblogger' ), $status, 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,
|
|
];
|
|
}
|
|
}
|