feat(images): Cloudflare Workers AI image provider (commit 1a)

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
This commit is contained in:
peptiderepo
2026-04-15 10:11:10 +07:00
parent 018a78000c
commit 11108b3bb5
10 changed files with 973 additions and 0 deletions

View File

@@ -57,6 +57,11 @@ class PRAutoBlogger_Settings_Fields {
'icon' => 'dashicons-chart-area',
'description' => __( 'Connect Google Analytics 4 for post performance scoring.', 'prautoblogger' ),
],
'prautoblogger_images' => [
'title' => __( 'Images', 'prautoblogger' ),
'icon' => 'dashicons-format-image',
'description' => __( 'Cloudflare Workers AI credentials and image style controls.', 'prautoblogger' ),
],
];
}
@@ -329,6 +334,45 @@ class PRAutoBlogger_Settings_Fields {
'section' => 'prautoblogger_analytics',
'description' => __( 'Paste the full JSON key file for a service account with Analytics read access.', 'prautoblogger' ),
],
// ── Images ─────────────────────────────────────────────────
[
'id' => 'prautoblogger_cloudflare_ai_token',
'label' => __( 'Cloudflare API Token', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_images',
'description' => __( 'Workers AI token with Read + Edit scope. Create at dash.cloudflare.com → AI → API Tokens.', 'prautoblogger' ),
'icon' => '🔑',
],
[
'id' => 'prautoblogger_cloudflare_account_id',
'label' => __( 'Cloudflare Account ID', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_images',
'default' => '',
'description' => __( 'The Account ID shown on your Cloudflare dashboard sidebar. Used to build the Workers AI URL.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_model',
'label' => __( 'Image Model', 'prautoblogger' ),
'type' => 'select',
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL,
'options' => [
'flux-1-schnell' => __( 'FLUX.1 [schnell] — fast + cheap (default)', 'prautoblogger' ),
'flux-1-dev' => __( 'FLUX.1 [dev] — higher quality, ~4× cost', 'prautoblogger' ),
],
'description' => __( 'Schnell is the normal choice. Only switch to [dev] when a specific post needs higher quality.', 'prautoblogger' ),
'badge' => __( 'Low cost', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_image_style_suffix',
'label' => __( 'Style Suffix', 'prautoblogger' ),
'type' => 'textarea',
'section' => 'prautoblogger_images',
'default' => PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_SUFFIX,
'description' => __( 'Appended to every image prompt. Controls the visual look across all placements. Changing this mid-run will cause visible style drift — prefer changing during quiet periods.', 'prautoblogger' ),
],
];
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* Pricing + model-id resolution helpers for the Cloudflare image provider.
*
* Kept separate from the provider so the provider stays under the 300-line
* cap and so cost estimation can be unit-tested without spinning up HTTP
* mocks. Mirrors the `class-open-router-pricing.php` split.
*
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider (both generate_image()
* and estimate_cost() delegate here).
* Dependencies: None — pure calculation + option reads.
*
* @see class-cloudflare-image-provider.php — Caller.
* @see class-open-router-pricing.php — Sibling pattern for LLM pricing.
*/
class PRAutoBlogger_Cloudflare_Image_Pricing {
/**
* Cloudflare Workers AI FLUX pricing (USD per megapixel), April 2026.
* Single source of truth — update here if Cloudflare repricies.
*/
private const COST_PER_MP_SCHNELL_USD = 0.0011;
private const COST_MULTIPLIER_DEV = 4.0;
/**
* Short-alias → fully-qualified Workers AI model id.
*
* @var array<string, string>
*/
private const MODEL_ALIAS_MAP = [
'flux-1-schnell' => '@cf/black-forest-labs/flux-1-schnell',
'flux-1-dev' => '@cf/black-forest-labs/flux-1-dev',
];
/**
* 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. Schnell is the baseline;
* [dev] is a ~4x multiplier. 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;
if ( false !== strpos( $model, 'flux-1-dev' ) ) {
$rate *= self::COST_MULTIPLIER_DEV;
}
return round( $mp * $rate, 6 );
}
}

View File

@@ -0,0 +1,284 @@
<?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-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;
public function __construct() {
$this->pricing = new PRAutoBlogger_Cloudflare_Image_Pricing();
}
/**
* 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->get_account_id();
$api_token = $this->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->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->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->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->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,
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,
$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. */
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 raw or JSON-wrapped. */
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' )
);
}
private function build_endpoint_url( string $account_id, string $model ): string {
return sprintf(
'https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s',
rawurlencode( $account_id ),
ltrim( $model, '/' )
);
}
private function get_api_token(): string {
$encrypted = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
if ( '' === $encrypted ) {
return '';
}
return (string) PRAutoBlogger_Encryption::decrypt( $encrypted );
}
private function get_account_id(): string {
return trim( (string) get_option( 'prautoblogger_cloudflare_account_id', '' ) );
}
private 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 the full secret). */
private 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'
);
}
/** Sleep between retries; Retry-After header wins when present, else exponential backoff. */
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 );
}
}
}

View File

@@ -0,0 +1,107 @@
<?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,
];
}
}

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
/**
* Contract for any image generation provider (Cloudflare Workers AI, DALL-E, etc.).
*
* Consumers depend on this interface, never on a concrete provider — swapping
* providers means writing one new class, not refactoring the pipeline. This
* mirrors the LLM provider pattern in `interface-llm-provider.php` (see CTO
* rule #7 — interface for external service integrations).
*
* Triggered by: PRAutoBlogger_Image_Pipeline (added in commit #1b) during the
* 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 — Default implementation (FLUX.1 via Workers AI).
* @see interface-llm-provider.php — Parallel pattern for LLM providers.
* @see ARCHITECTURE.md — External API Integrations table.
*/
interface PRAutoBlogger_Image_Provider_Interface {
/**
* Generate a single image from a prompt at the requested dimensions.
*
* Implementations MUST:
* - Apply retry + exponential backoff on transient failures (5xx, network).
* - Fail loudly on client errors (4xx) rather than silently returning empty.
* - Log each attempt via PRAutoBlogger_Logger.
* - Persist a structured event row via PRAutoBlogger_Event_Log (added in a
* later commit — safe to no-op until then).
*
* Side effects: HTTP request to the provider API.
*
* @param string $prompt Full prompt (concept + style suffix already concatenated).
* @param int $width Target width in pixels (>= 64, <= 2048).
* @param int $height Target height in pixels (>= 64, <= 2048).
* @param array{
* seed?: int,
* steps?: int,
* model?: string,
* } $options Optional tuning. `seed` lets the caller ask for visually
* coherent variants across placements. `steps` trades quality
* for cost (default model-dependent). `model` picks a specific
* model identifier; implementations may ignore values they
* don't support.
*
* @return array{
* bytes: string,
* mime_type: string,
* width: int,
* height: int,
* model: string,
* seed: ?int,
* cost_usd: float,
* latency_ms: int,
* } Raw image bytes (PNG or JPEG) plus metadata. Metadata is used by the
* caller to write post meta and to bill against the plugin's cost budget.
*
* @throws \RuntimeException On API error after retries exhausted, or on
* an invalid response shape.
* @throws \InvalidArgumentException If dimensions are out of range.
*/
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array;
/**
* Estimate the USD cost of a single generation call before making it.
*
* Used by the cost tracker to pre-check budget and to show an expected
* spend figure in the dry-run preview.
*
* @param int $width Target width in pixels.
* @param int $height Target height in pixels.
* @param array $options Same shape as generate_image() options.
*
* @return float Estimated cost in USD for a single image at the given dimensions.
*/
public function estimate_cost( int $width, int $height, array $options = [] ): float;
/**
* Machine-readable provider identifier used in logs and settings.
*
* @return string Lowercase identifier, e.g. 'cloudflare_workers_ai'.
*/
public function get_provider_name(): string;
/**
* Verify the provider credentials are present and the API is reachable.
*
* Used by the admin "Test Connections" action. Implementations should make
* a lightweight request (e.g. a models list or token verify endpoint) — NOT
* generate a real image.
*
* Side effects: HTTP request, logs the result.
*
* @return array{status: string, message: string, debug?: string}
* status is 'ok' or 'error'; message is human-readable for the
* admin UI; debug is optional diagnostic detail for logs.
*/
public function validate_credentials_detailed(): array;
}