Merge PR #105: v0.10.0 — remove Cloudflare Workers AI image provider
Removes CF Workers AI image provider entirely; migrates existing CF users to runware:100@1 at next admin_init. AI Gateway fronting OpenRouter unaffected. QA verdict: qa-reviews/prautoblogger/2026-04-21-b399023.md APPROVE. Engineer: prautoblogger (PR #105). Review + merge: cto.
This commit is contained in:
@@ -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.
|
||||
|
||||
63
CHANGELOG.md
63
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
|
||||
|
||||
@@ -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 ) {
|
||||
|
||||
@@ -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 ) {
|
||||
|
||||
@@ -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' ),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 ) {
|
||||
|
||||
@@ -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 );
|
||||
|
||||
|
||||
99
includes/class-migrate-remove-cloudflare-v0100.php
Normal file
99
includes/class-migrate-remove-cloudflare-v0100.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* One-shot v0.10.0 migration — remove Cloudflare Workers AI as image provider.
|
||||
*
|
||||
* Cloudflare Workers AI was removed as an image provider in v0.10.0.
|
||||
* Runware FLUX.1 schnell is 65× cheaper at equivalent quality, so any user
|
||||
* still on a `cloudflare` provider must be migrated off — otherwise the
|
||||
* image pipeline throws "no provider for id cloudflare" at the first
|
||||
* generation run after upgrade.
|
||||
*
|
||||
* Split out of class-activator.php so that file stays under the 300-line
|
||||
* cap. The activator's maybe_run_db_migrations() delegates here.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger::maybe_run_db_migrations() once per admin_init
|
||||
* until the `prautoblogger_migrated_remove_cloudflare_v0100`
|
||||
* flag is set.
|
||||
* Dependencies: PRAutoBlogger_Image_Model_Registry::provider_for() for
|
||||
* current-model provider derivation.
|
||||
*
|
||||
* @see class-activator.php — Parallel v0.9.0 migration.
|
||||
* @see class-prautoblogger.php — Calls run() during admin_init.
|
||||
* @see class-image-model-registry.php — Provider derivation source.
|
||||
* @see convo/prautoblogger/threads/2026-04-cloudflare-workers-ai-removal/
|
||||
*/
|
||||
class PRAutoBlogger_Migrate_Remove_Cloudflare_V0100 {
|
||||
|
||||
/**
|
||||
* Migration flag option key. Presence = already migrated.
|
||||
*/
|
||||
private const FLAG = 'prautoblogger_migrated_remove_cloudflare_v0100';
|
||||
|
||||
/**
|
||||
* Run the migration. Idempotent — second + later calls are no-ops.
|
||||
*
|
||||
* Side effects: may update `prautoblogger_image_model` +
|
||||
* `prautoblogger_image_provider`; always deletes
|
||||
* `prautoblogger_cloudflare_ai_token`,
|
||||
* `prautoblogger_cloudflare_account_id`,
|
||||
* `prautoblogger_cf_image_via_gateway`; sets the
|
||||
* migration flag option.
|
||||
*
|
||||
* Explicitly does NOT touch `prautoblogger_ai_gateway_base_url` —
|
||||
* that option is shared with the OpenRouter AI Gateway route and
|
||||
* must stay intact.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function run(): void {
|
||||
if ( get_option( self::FLAG ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( self::is_user_on_cloudflare() ) {
|
||||
// Closest equivalent — FLUX.1 schnell, Runware-hosted, ~$0.0006/image.
|
||||
update_option( 'prautoblogger_image_model', 'runware:100@1' );
|
||||
update_option( 'prautoblogger_image_provider', 'runware' );
|
||||
}
|
||||
|
||||
// Clean up orphaned CF Workers AI credentials + the dead gateway toggle.
|
||||
delete_option( 'prautoblogger_cloudflare_ai_token' );
|
||||
delete_option( 'prautoblogger_cloudflare_account_id' );
|
||||
delete_option( 'prautoblogger_cf_image_via_gateway' );
|
||||
|
||||
update_option( self::FLAG, '1' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the site is currently configured to use Cloudflare
|
||||
* Workers AI for image generation.
|
||||
*
|
||||
* Because the v0.10.0 image-model registry no longer contains
|
||||
* Cloudflare entries, `provider_for()` returns '' for legacy
|
||||
* `flux-1-schnell` slugs. We therefore look at three signals and
|
||||
* treat any of them as "on Cloudflare":
|
||||
* 1. Current registry-derived provider === 'cloudflare'.
|
||||
* 2. The legacy `prautoblogger_image_provider` option is 'cloudflare'.
|
||||
* 3. The legacy CF-specific slug `flux-1-schnell` is still persisted
|
||||
* as the image model (pre-registry-rename installs).
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_user_on_cloudflare(): bool {
|
||||
$curr = (string) get_option( 'prautoblogger_image_model', '' );
|
||||
$curr_provider = PRAutoBlogger_Image_Model_Registry::provider_for( $curr );
|
||||
|
||||
if ( 'cloudflare' === $curr_provider ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$legacy_provider = (string) get_option( 'prautoblogger_image_provider', '' );
|
||||
if ( 'cloudflare' === $legacy_provider ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'flux-1-schnell' === $curr;
|
||||
}
|
||||
}
|
||||
@@ -220,7 +220,6 @@ class PRAutoBlogger {
|
||||
}
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v3', '1' );
|
||||
}
|
||||
|
||||
// One-time migration (v4): remove caption-in-image instruction from style suffix.
|
||||
// Captions are now inserted as HTML below the image, not baked into the image.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_suffix_v4' ) ) {
|
||||
@@ -232,8 +231,9 @@ class PRAutoBlogger {
|
||||
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
|
||||
}
|
||||
|
||||
// v0.9.0: flip legacy default image model to Runware schnell.
|
||||
// v0.9.0 — Runware as default image model. v0.10.0 — remove CF Workers AI.
|
||||
PRAutoBlogger_Activator::migrate_default_image_v090();
|
||||
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||
|
||||
// One-time migration: re-wrap existing encrypted values with "enc:" prefix.
|
||||
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -54,19 +54,17 @@ class PRAutoBlogger_Image_Pipeline {
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate the image provider based on the admin setting.
|
||||
* Instantiate the image provider based on the admin setting. Defaults
|
||||
* to Runware on unrecognised values (CF was removed in v0.10.0).
|
||||
*
|
||||
* @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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
<?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 reprices.
|
||||
*/
|
||||
private const COST_PER_MP_SCHNELL_USD = 0.0011;
|
||||
|
||||
/**
|
||||
* Short-alias → fully-qualified Workers AI model id.
|
||||
*
|
||||
* FLUX.1 [dev] was removed from Cloudflare Workers AI in April 2026
|
||||
* (returns HTTP 400 "No route for that URI"). Only schnell remains.
|
||||
* Any saved setting referencing flux-1-dev falls through to schnell
|
||||
* via the null-coalesce in resolve_model().
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
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 );
|
||||
}
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
<?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 {
|
||||
// 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 );
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Small support helpers for the Cloudflare image provider — credential
|
||||
* lookups, URL building, and structured log lines.
|
||||
*
|
||||
* Split out of the provider so the provider stays under the 300-line cap
|
||||
* and so these tiny pieces are individually testable. Pure functions plus
|
||||
* one logger forward; no state.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Cloudflare_Image_Provider only.
|
||||
* Dependencies: PRAutoBlogger_Encryption (token decryption),
|
||||
* PRAutoBlogger_Logger (WARN/ERROR forwarding).
|
||||
*
|
||||
* @see class-cloudflare-image-provider.php Sole caller.
|
||||
*/
|
||||
class PRAutoBlogger_Cloudflare_Image_Support {
|
||||
|
||||
/**
|
||||
* Decrypt the Cloudflare API token from the settings option.
|
||||
*
|
||||
* @return string Plaintext token, or empty string if not set / decrypt fails.
|
||||
*/
|
||||
public function get_api_token(): string {
|
||||
$stored = (string) get_option( 'prautoblogger_cloudflare_ai_token', '' );
|
||||
if ( '' === $stored ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If the token is already encrypted (has "enc:" prefix), decrypt it.
|
||||
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
|
||||
return (string) PRAutoBlogger_Encryption::decrypt( $stored );
|
||||
}
|
||||
|
||||
// Legacy: token was saved as plaintext before the field was added to
|
||||
// the encrypted list in sanitize_field(). Encrypt it now so future
|
||||
// reads go through the normal path, then return the plaintext value.
|
||||
$cipher = PRAutoBlogger_Encryption::encrypt( $stored );
|
||||
if ( '' !== $cipher ) {
|
||||
update_option( 'prautoblogger_cloudflare_ai_token', $cipher );
|
||||
}
|
||||
return $stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Cloudflare account ID from settings.
|
||||
*
|
||||
* @return string Account UUID, or empty string.
|
||||
*/
|
||||
public function get_account_id(): string {
|
||||
return trim( (string) get_option( 'prautoblogger_cloudflare_account_id', '' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Workers AI run URL for a given account + model.
|
||||
*
|
||||
* By default routes through the Cloudflare AI Gateway we already use for
|
||||
* OpenRouter — the gateway route to Workers AI was observed 403ing in
|
||||
* April 2026 (see ARCHITECTURE.md #16), but manual probe on 2026-04-21
|
||||
* shows it's now returning 200. Gateway routing gives us response caching,
|
||||
* cost logging, rate limiting, and provider fallback for free.
|
||||
*
|
||||
* Direct-API fallback kicks in automatically when either the
|
||||
* `prautoblogger_ai_gateway_base_url` option is empty (no gateway
|
||||
* configured at all) or the new `prautoblogger_cf_image_via_gateway`
|
||||
* toggle is off (explicit opt-out for if the gateway route regresses).
|
||||
*
|
||||
* @param string $account_id Cloudflare account UUID.
|
||||
* @param string $model Fully-qualified Workers AI model id (e.g. @cf/black-forest-labs/flux-1-schnell).
|
||||
* @return string Fully-formed HTTPS URL.
|
||||
*/
|
||||
public function build_endpoint_url( string $account_id, string $model ): string {
|
||||
$gateway_url = $this->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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
<?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' )
|
||||
);
|
||||
}
|
||||
|
||||
$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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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__ ) );
|
||||
@@ -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
|
||||
|
||||
@@ -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.' );
|
||||
|
||||
163
tests/unit/Core/ActivatorRemoveCloudflareTest.php
Normal file
163
tests/unit/Core/ActivatorRemoveCloudflareTest.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for the v0.10.0 one-shot migration that removes Cloudflare
|
||||
* Workers AI as an image provider — exercises the cloudflare-user
|
||||
* path, the non-cloudflare-user path, idempotency, and the critical
|
||||
* scope-boundary invariant that `prautoblogger_ai_gateway_base_url`
|
||||
* (shared with the OpenRouter AI Gateway route) is left intact.
|
||||
*
|
||||
* See thread 2026-04-cloudflare-workers-ai-removal/01-cto-handoff.md.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class ActivatorRemoveCloudflareTest extends BaseTestCase {
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
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'] );
|
||||
}
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Cloudflare_Image_Provider.
|
||||
*
|
||||
* Validates the image provider interface implementation methods.
|
||||
* All HTTP calls are mocked — no real API calls.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Providers
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Providers;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class CloudflareImageProviderTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* Fake PNG payload — bytes don't need to be a real PNG, just non-empty.
|
||||
* Using a short string keeps failure messages readable.
|
||||
*/
|
||||
private const FAKE_IMAGE_BYTES = 'FAKE_PNG_BYTES';
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
// PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant is defined in
|
||||
// prautoblogger.php — define here for test isolation.
|
||||
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
|
||||
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'flux-1-schnell' );
|
||||
}
|
||||
|
||||
$this->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' => '<!doctype html><html><body>oops</body></html>',
|
||||
'response' => [ 'code' => 200 ],
|
||||
'headers' => [ 'content-type' => 'text/html' ],
|
||||
] );
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
|
||||
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '<!doctype html><html><body>oops</body></html>' );
|
||||
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'] );
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Cloudflare_Image_Support — URL building in
|
||||
* particular. The v0.8.2 gateway migration added a derivation path from
|
||||
* the existing `prautoblogger_ai_gateway_base_url` OpenRouter gateway
|
||||
* URL to the corresponding Workers AI URL at the same gateway root.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Providers
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Providers;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class CloudflareImageSupportTest extends BaseTestCase {
|
||||
|
||||
private const ACCOUNT_ID = 'a16f4baa82a1074c47f696e7cb6d5995';
|
||||
private const MODEL = '@cf/black-forest-labs/flux-1-schnell';
|
||||
|
||||
/**
|
||||
* With no gateway URL configured, the support class returns the direct
|
||||
* Workers AI API URL — the historical behaviour prior to v0.8.2.
|
||||
*/
|
||||
public function test_returns_direct_url_when_gateway_not_configured(): void {
|
||||
$this->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( '{}' )
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user