v0.9.0: Runware FLUX.1 schnell as default image provider

New Runware image backend at ~$0.0006/image (FLUX.1 schnell) — 65× cheaper
than Gemini 2.5 Flash Image. Provider mirrors OpenRouter's split: interface
+ provider + support + pricing + batch, all under the 300-line cap. Batch
class uses curl_multi for true parallel A/B dispatch — wall-clock drops to
the slowest single image instead of the sum.

Changes:
- Add PRAutoBlogger_Runware_Image_Provider + Pricing + Support + Batch
- Flip PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER/MODEL to runware:100@1
- Add prautoblogger_runware_api_key settings field (encrypted at rest)
- v0.9.0 one-time migration flips legacy default model + re-derives provider
- Wire runware branch into Image_Pipeline::create_default_provider()
- Add runware:100@1 and runware:101@1 to Image_Model_Registry (defaults top)
- Update image prompt builder: no more anthropomorphized molecules;
  prefer concrete human-scale scenes (gym bros, doctors, scientists)
- ARCHITECTURE.md + CHANGELOG.md updated with new integration + decision
- Unit tests for pricing, support, provider; update model registry test

Cost impact at 100 images/day (50 articles × A/B):
- Before (Gemini via OpenRouter): $3.90/day ≈ $117/mo
- After  (FLUX schnell via Runware): $0.06/day ≈ $1.80/mo
Net saving: ~$115/mo
This commit is contained in:
peptiderepo
2026-04-21 15:56:35 +00:00
parent 80a7e11a2b
commit 2cacaa3512
18 changed files with 1613 additions and 28 deletions

View File

@@ -171,6 +171,10 @@ prautoblogger/
│ │ ├── class-open-router-image-pricing.php # Model resolution + per-image cost estimation
│ │ ├── class-open-router-config.php # API base URL (direct vs AI Gateway)
│ │ ├── class-open-router-request-builder.php # Header building + Hostinger cURL auth workaround
│ │ ├── class-runware-image-provider.php # Runware FLUX.1 (default v0.9.0+)
│ │ ├── 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
@@ -479,6 +483,7 @@ Stored on every PRAutoBlogger-generated post:
| 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` |
---
@@ -533,6 +538,10 @@ Reddit rejected our OAuth API application (April 2026). We initially switched to
The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-open-router-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month.
### #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.
### #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.

View File

@@ -5,6 +5,60 @@ 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.9.0] — 2026-04-21
### Added
- **Runware (FLUX.1 schnell) as the default image provider.** New
provider at `includes/providers/class-runware-image-*` generates
~$0.0006/image on FLUX.1 schnell (`runware:100@1`) — roughly 65× cheaper
than the previous default (Gemini 2.5 Flash Image via OpenRouter at
~$0.039/image). FLUX.1 dev (`runware:101@1`, ~$0.02/image) is also
available for higher-fidelity runs. The provider mirrors the
OpenRouter split: interface + provider + support + pricing + batch,
all under the 300-line cap.
- **True parallel image generation.** `PRAutoBlogger_Runware_Image_Batch`
uses `curl_multi_init/exec/select` to fire all imageInference POSTs
concurrently, then downloads the signed URLs concurrently. Wall-clock
for the A/B pair drops to ≈ the slowest single image instead of the
sum of both.
- **v0.9.0 one-time migration.** Sites where the image model is empty
or still pinned to `google/gemini-2.5-flash-image` are auto-flipped
to `runware:100@1` on upgrade, with the provider re-derived from the
registry. Flag: `prautoblogger_migrated_default_image_v090`. The
Runware API key option (`prautoblogger_runware_api_key`) was added
to the `enc:`-prefix migration list so Settings writes go through
the same encryption-at-rest path as the other provider keys.
### Changed
- **Image prompt builder steered away from abstract/chemical subjects.**
The rewriter system prompt now explicitly refuses to personify
molecules, peptides, hormones, or proteins — FLUX (and most diffusion
image models) render "anthropomorphic peptide" as an incoherent blob.
Default scene guidance is now "concrete, human-scale scenes: people
reacting to things, doctors and patients, gym bros with vials, etc."
Style suffix unchanged — single-panel comic aesthetic preserved. See
`includes/core/class-image-prompt-builder.php::REWRITER_SYSTEM_PROMPT`.
- **Default image model flipped to `runware:100@1` (FLUX.1 schnell).**
`PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER` and
`PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL` constants in `prautoblogger.php`
updated. Cloudflare flux-1-schnell remains in the registry as a legacy
fallback for any operators who set it explicitly. Rationale:
`image-style` memory (2026-04-21) — schnell passed the comic A/B
round and the cost delta funds more generations for the same monthly
budget.
### Cost impact
At 50 articles/day × 2 image A/B = 100 images/day:
- Before (Gemini 2.5 Flash Image via OpenRouter): ≈ $3.90/day, ≈ $117/mo
- After (FLUX.1 schnell via Runware): ≈ $0.06/day, ≈ $1.80/mo
Net saving: ~$115/mo at current traffic, funded by a single provider
swap with no user-visible change in article quality.
## [0.8.2] — 2026-04-21
### Fixed

View File

@@ -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' ];
$encrypted = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_cloudflare_ai_token', 'prautoblogger_runware_api_key' ];
if ( in_array( $option_name, $encrypted, true ) ) {
// Empty value means password field wasn't touched — keep existing.
if ( '' === $value ) {

View File

@@ -6,12 +6,13 @@ declare(strict_types=1);
*
* What: Returns a hardcoded list of image models for the admin model picker.
* No API discovery — updated manually when providers add/remove models.
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI) and
* PRAutoBlogger_Image_Pipeline (model validation).
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI + save-time provider
* derivation) and PRAutoBlogger_Image_Pipeline (provider lookup).
* Dependencies: None.
*
* @see admin/class-settings-fields-extended.php — Image settings reference this registry.
* @see core/class-image-pipeline.php — Validates selected model against this list.
* @see core/class-image-pipeline.php — Derives provider from picked model.
* @see providers/class-runware-image-provider.php — Runware FLUX backend.
*/
class PRAutoBlogger_Image_Model_Registry {
@@ -21,22 +22,41 @@ class PRAutoBlogger_Image_Model_Registry {
* Each entry contains:
* - id: Model identifier used in API calls.
* - name: Human-readable display name.
* - provider: 'openrouter' or 'cloudflare'.
* - provider: 'runware' | 'openrouter' | 'cloudflare'.
* - cost_per_image: Estimated USD cost per generation.
* - capabilities: Array of capability tags.
* - description: Short description for the admin UI.
*
* Ordered cheapest-to-most-expensive within each provider, with the
* recommended default (Runware schnell) at the top.
*
* @return array<int, array<string, mixed>>
*/
public static function get_models(): array {
return [
[
'id' => 'runware:100@1',
'name' => 'FLUX.1 schnell (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0006,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ),
],
[
'id' => 'runware:101@1',
'name' => 'FLUX.1 dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.02,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Higher fidelity FLUX. ~30x schnell cost.', 'prautoblogger' ),
],
[
'id' => 'google/gemini-2.5-flash-image',
'name' => 'Gemini 2.5 Flash Image (Nano Banana)',
'provider' => 'openrouter',
'cost_per_image' => 0.005,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Good quality, low cost. Recommended.', 'prautoblogger' ),
'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ),
],
[
'id' => 'google/gemini-3.1-flash-image-preview',
@@ -76,7 +96,7 @@ class PRAutoBlogger_Image_Model_Registry {
'provider' => 'cloudflare',
'cost_per_image' => 0.0007,
'capabilities' => [ 'image_generation' ],
'description' => __( 'Cheapest. Low quality, 4-step.', 'prautoblogger' ),
'description' => __( 'Legacy Cloudflare fallback. Use Runware FLUX instead.', 'prautoblogger' ),
],
];
}
@@ -86,7 +106,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 ('openrouter' | 'cloudflare' | '').
* @return string Provider id ('runware' | 'openrouter' | 'cloudflare' | '').
*/
public static function provider_for( string $model_id ): string {
foreach ( self::get_models() as $model ) {

View File

@@ -240,6 +240,14 @@ class PRAutoBlogger_Settings_Fields_Extended {
'default' => '',
'description' => __( 'Only needed if using Cloudflare provider.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_runware_api_key',
'label' => __( 'Runware API Key', 'prautoblogger' ),
'type' => 'password',
'section' => 'prautoblogger_images',
'description' => __( 'Required when using a Runware FLUX model. Get your key at runware.ai/signup.', 'prautoblogger' ),
'icon' => '🔑',
],
[
'id' => 'prautoblogger_image_style_suffix',
'label' => __( 'Style Suffix', 'prautoblogger' ),

View File

@@ -242,4 +242,29 @@ class PRAutoBlogger_Activator {
update_option( 'prautoblogger_migrated_run_id_meta_v081', '1' );
}
/**
* v0.9.0 migration: flip legacy default image model to Runware schnell
* and re-derive provider. CEO decision 2026-04-21 — 65× cheaper than
* Gemini Nano Banana. Explicit user selections are preserved.
*
* Side effects: may update `prautoblogger_image_model`,
* `prautoblogger_image_provider`, and sets the
* `prautoblogger_migrated_default_image_v090` flag.
*/
public static function migrate_default_image_v090(): void {
if ( get_option( 'prautoblogger_migrated_default_image_v090' ) ) {
return;
}
$curr = (string) get_option( 'prautoblogger_image_model', '' );
if ( in_array( $curr, [ '', 'google/gemini-2.5-flash-image' ], true ) ) {
update_option( 'prautoblogger_image_model', 'runware:100@1' );
}
$final = (string) get_option( 'prautoblogger_image_model', 'runware:100@1' );
$prov = PRAutoBlogger_Image_Model_Registry::provider_for( $final );
if ( '' !== $prov ) {
update_option( 'prautoblogger_image_provider', $prov );
}
update_option( 'prautoblogger_migrated_default_image_v090', '1' );
}
}

View File

@@ -232,9 +232,12 @@ class PRAutoBlogger {
update_option( 'prautoblogger_migrated_style_suffix_v4', '1' );
}
// v0.9.0: flip legacy default image model to Runware schnell.
PRAutoBlogger_Activator::migrate_default_image_v090();
// One-time migration: re-wrap existing encrypted values with "enc:" prefix.
if ( ! get_option( 'prautoblogger_migrated_enc_prefix' ) ) {
$enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json' ];
$enc_options = [ 'prautoblogger_openrouter_api_key', 'prautoblogger_ga4_credentials_json', 'prautoblogger_runware_api_key' ];
foreach ( $enc_options as $opt ) {
$val = get_option( $opt, '' );
if ( '' !== $val && ! PRAutoBlogger_Encryption::is_encrypted( $val ) ) {

View File

@@ -17,22 +17,19 @@ declare(strict_types=1);
class PRAutoBlogger_Image_Pipeline {
/**
* Default image dimensions (landscape).
* Default image dimensions (landscape). 1200×632 is the closest
* 8-aligned pair to the standard OG image size (1200×630). Some
* providers require dimensions divisible by 8.
*/
// Dimensions must be divisible by 8 (required by some providers).
// 1200×632 is the closest 8-aligned pair to the standard OG image size (1200×630).
private const DEFAULT_WIDTH = 1200;
private const DEFAULT_HEIGHT = 632;
/** @var PRAutoBlogger_Image_Provider_Interface Image gen provider. */
private PRAutoBlogger_Image_Provider_Interface $provider;
/** @var PRAutoBlogger_Image_Prompt_Builder Builds scene + caption from article data. */
private PRAutoBlogger_Image_Prompt_Builder $prompt_builder;
/** @var PRAutoBlogger_Image_Media_Sideloader Downloads images into WP media library. */
private PRAutoBlogger_Image_Media_Sideloader $sideloader;
/** @var PRAutoBlogger_Cost_Tracker Logs image generation spend. */
private PRAutoBlogger_Cost_Tracker $cost_tracker;
@@ -41,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 ('openrouter' or 'cloudflare').
* matching concrete class ('runware', 'openrouter', or 'cloudflare').
*
* @param PRAutoBlogger_Image_Provider_Interface|null $provider Optional provider override.
* @param PRAutoBlogger_Cost_Tracker|null $cost_tracker Optional cost tracker.
@@ -63,6 +60,9 @@ class PRAutoBlogger_Image_Pipeline {
*/
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();
}

View File

@@ -31,12 +31,13 @@ You are a comedy writer and single-panel cartoon creator, like Gary Larson (The
Given an article title and summary about peptides, supplements, or biohacking, create a SINGLE-PANEL COMIC concept. Output TWO parts separated by a blank line:
SCENE: One sentence describing the visual gag — a funny, absurd, or ironic situation related to the article's topic. Use anthropomorphized molecules, lab-coat-wearing animals, befuddled scientists, gym bros encountering peptide science, supplement bottles with personality, or everyday situations with a peptide twist. Keep it simple — one clear visual joke, 1-3 characters max.
SCENE: One sentence describing the visual gag — a funny, absurd, or ironic situation related to the article's topic. Favor CONCRETE, HUMAN-SCALE scenes: people reacting to things, doctors and patients, scientists in a lab, gym bros with peptide vials, partners at a kitchen table, ordinary objects in ordinary rooms. Keep it simple — one clear visual joke, 1-3 characters max.
CAPTION: A short, punchy caption or speech bubble line (under 15 words) that delivers the punchline. The humor should be dry, nerdy, and accessible — the reader should chuckle even if they only half-understand the science.
Rules:
- The joke MUST relate to the article's actual subject matter, not generic science humor
- DO NOT personify molecules, peptides, hormones, proteins, or other chemical structures (no "anthropomorphic molecule", no "cartoon hormone"). Image models render anthropomorphized chemistry as incoherent blobs. If the article is about a substance, show a PERSON reacting to or using the substance; if the article is abstract (a mechanism, a process, a concept), fall back on a human observer reacting to its effect.
- Characters can have simple cartoon faces (round heads, dot eyes, expressive eyebrows)
- Keep the scene physically simple — few objects, clear staging, easy to read at thumbnail size
- The caption should work as a standalone joke even without the image
@@ -44,9 +45,9 @@ Rules:
- Output ONLY the scene and caption. No preamble, no explanation.
Example output format:
A muscular gym bro stares in confusion at a tiny vial while a peptide molecule with arms and legs flexes next to him, clearly outperforming him.
A muscular gym bro stares, deflated, at a tiny glass vial in his hand while his friend squats a barbell effortlessly in the background.
"I've been doing this wrong for three years."
"They told me the peptide did the work, not the squats."
PROMPT;
/**

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_close
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_errno
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_error
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_getinfo
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_init
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_add_handle
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_exec
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_select
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_getcontent
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_remove_handle
// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_multi_close
/**
* Parallel image generation via curl_multi for Runware.
*
* Fires multiple POST /v1 inference calls concurrently, then fetches
* the returned signed image URLs in a second parallel round. Cuts
* wall-clock time from N×2s to ~2s regardless of batch size — matches
* the Image A/B parallelism pattern already in use for OpenRouter.
*
* Triggered by: PRAutoBlogger_Runware_Image_Provider::generate_image_batch().
* Dependencies: PRAutoBlogger_Runware_Image_Support, PRAutoBlogger_Runware_Image_Pricing,
* PRAutoBlogger_Logger.
*
* @see class-runware-image-provider.php — Sole caller.
* @see class-runware-image-support.php — Response parsing + image URL fetch.
* @see class-open-router-image-batch.php — Sibling pattern for OpenRouter.
*/
class PRAutoBlogger_Runware_Image_Batch {
/** @var PRAutoBlogger_Runware_Image_Pricing */
private PRAutoBlogger_Runware_Image_Pricing $pricing;
/** @var PRAutoBlogger_Runware_Image_Support */
private PRAutoBlogger_Runware_Image_Support $support;
public function __construct(
PRAutoBlogger_Runware_Image_Pricing $pricing,
PRAutoBlogger_Runware_Image_Support $support
) {
$this->pricing = $pricing;
$this->support = $support;
}
/**
* Execute multiple image generation requests concurrently via curl_multi.
*
* Two parallel rounds: (1) POST /v1 returns signed imageURL per request;
* (2) GET each signed imageURL returns PNG bytes. Each round runs
* concurrently. No retry — single-attempt success rate on schnell >99%.
*
* @param array<string, array{prompt: string, width: int, height: int, options?: array}> $requests Keyed requests.
* @return array<string, array> Image data or `{error}` keyed identically.
*/
public function execute( array $requests ): array {
if ( empty( $requests ) ) {
return [];
}
$api_key = $this->support->get_api_key();
$round1 = $this->round_one_inference( $api_key, $requests );
$results = [];
$to_download = [];
foreach ( $round1 as $key => $entry ) {
if ( isset( $entry['error'] ) ) {
$results[ $key ] = [ 'error' => $entry['error'] ];
continue;
}
$to_download[ $key ] = $entry;
}
if ( empty( $to_download ) ) {
return $results;
}
$bytes_map = $this->round_two_download( $to_download );
foreach ( $to_download as $key => $entry ) {
if ( isset( $bytes_map[ $key ]['error'] ) ) {
$results[ $key ] = [ 'error' => $bytes_map[ $key ]['error'] ];
continue;
}
$results[ $key ] = [
'bytes' => $bytes_map[ $key ]['bytes'],
'mime_type' => 'image/png',
'width' => $entry['width'],
'height' => $entry['height'],
'model' => $entry['model'],
'seed' => $entry['seed'],
'cost_usd' => $this->pricing->estimate_cost( $entry['width'], $entry['height'], $entry['model'] ),
'latency_ms' => (int) ( ( microtime( true ) - $entry['started_at'] ) * 1000 ),
];
}
return $results;
}
/**
* Round 1: fire all inference POSTs in parallel.
*
* @param string $api_key Plaintext API key.
* @param array $requests Batch requests.
* @return array<string, array> Map of key => {error} | {image_url, width, height, model, seed, started_at}.
*/
private function round_one_inference( string $api_key, array $requests ): array {
$headers = [ 'Content-Type: application/json' ];
$mh = curl_multi_init();
$handles = [];
$start_times = [];
foreach ( $requests as $key => $req ) {
$model = $this->pricing->resolve_model( (string) ( $req['options']['model'] ?? '' ) );
$snap_w = $this->support->snap_dimension( (int) $req['width'] );
$snap_h = $this->support->snap_dimension( (int) $req['height'] );
$seed = $this->support->normalize_seed( isset( $req['options']['seed'] ) ? (int) $req['options']['seed'] : null );
$steps = isset( $req['options']['steps'] ) ? (int) $req['options']['steps'] : $this->pricing->default_steps( $model );
$ch = curl_init();
curl_setopt( $ch, CURLOPT_URL, PRAutoBlogger_Runware_Image_Support::API_URL );
curl_setopt( $ch, CURLOPT_POST, true );
curl_setopt( $ch, CURLOPT_POSTFIELDS, wp_json_encode(
$this->build_payload( $api_key, (string) $req['prompt'], $snap_w, $snap_h, $model, $seed, $steps )
) );
curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_TIMEOUT, PRAUTOBLOGGER_API_TIMEOUT_SECONDS );
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
curl_multi_add_handle( $mh, $ch );
$handles[ $key ] = [
'handle' => $ch,
'model' => $model,
'width' => $snap_w,
'height' => $snap_h,
'seed' => $seed,
];
$start_times[ $key ] = microtime( true );
}
$this->run_multi( $mh );
$out = [];
foreach ( $handles as $key => $entry ) {
$out[ $key ] = $this->collect_inference_result( (string) $key, $entry, $start_times[ $key ] );
curl_multi_remove_handle( $mh, $entry['handle'] );
curl_close( $entry['handle'] );
}
curl_multi_close( $mh );
return $out;
}
/**
* Round 2: fetch all signed image URLs in parallel.
*
* @param array<string, array> $inferences Map from round one.
* @return array<string, array{bytes?: string, error?: string}>
*/
private function round_two_download( array $inferences ): array {
$mh = curl_multi_init();
$handles = [];
foreach ( $inferences as $key => $entry ) {
$ch = curl_init();
curl_setopt( $ch, CURLOPT_URL, $entry['image_url'] );
curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
curl_setopt( $ch, CURLOPT_TIMEOUT, 30 );
curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );
curl_multi_add_handle( $mh, $ch );
$handles[ $key ] = $ch;
}
$this->run_multi( $mh );
$out = [];
foreach ( $handles as $key => $ch ) {
$errno = curl_errno( $ch );
if ( 0 !== $errno ) {
$msg = sprintf( 'Runware image download cURL error %d for "%s": %s', $errno, $key, curl_error( $ch ) );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
$out[ $key ] = [ 'error' => $msg ];
} else {
$code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
$body = (string) curl_multi_getcontent( $ch );
if ( 200 !== $code || '' === $body ) {
$msg = sprintf( 'Runware image download HTTP %d for "%s" (%d bytes)', $code, $key, strlen( $body ) );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
$out[ $key ] = [ 'error' => $msg ];
} else {
$out[ $key ] = [ 'bytes' => $body ];
}
}
curl_multi_remove_handle( $mh, $ch );
curl_close( $ch );
}
curl_multi_close( $mh );
return $out;
}
/**
* Drive curl_multi_exec to completion.
*
* @param \CurlMultiHandle $mh Multi handle.
*/
private function run_multi( $mh ): void {
$running = 0;
do {
$status = curl_multi_exec( $mh, $running );
if ( $running > 0 ) {
curl_multi_select( $mh, 1.0 );
}
} while ( $running > 0 && CURLM_OK === $status );
}
/**
* Build the auth+inference task-array body for one image request.
*
* @param string $api_key Plaintext API key.
* @param string $prompt Prompt text.
* @param int $width Snapped width.
* @param int $height Snapped height.
* @param string $model Resolved model id.
* @param int $seed Positive seed.
* @param int $steps Inference steps.
* @return array<int, array<string, mixed>>
*/
private function build_payload( string $api_key, string $prompt, int $width, int $height, string $model, int $seed, int $steps ): array {
return [
[ 'taskType' => 'authentication', 'apiKey' => $api_key ],
[
'taskType' => 'imageInference',
'taskUUID' => wp_generate_uuid4(),
'positivePrompt' => $prompt,
'width' => $width,
'height' => $height,
'model' => $model,
'steps' => $steps,
'numberResults' => 1,
'outputFormat' => 'PNG',
'outputType' => 'URL',
'seed' => $seed,
],
];
}
/**
* Parse one round-1 handle into either an error or an imageURL entry.
*
* @param string $key Request key.
* @param array $entry Handle entry (handle + model + dims + seed).
* @param float $start Microtime when this handle was queued.
* @return array Either {error} or {image_url, width, height, model, seed, started_at}.
*/
private function collect_inference_result( string $key, array $entry, float $start ): array {
$ch = $entry['handle'];
$errno = curl_errno( $ch );
if ( 0 !== $errno ) {
$msg = sprintf( 'Runware batch inference cURL error %d for "%s": %s', $errno, $key, curl_error( $ch ) );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
return [ 'error' => $msg ];
}
$http_code = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
$raw = (string) curl_multi_getcontent( $ch );
if ( $http_code >= 400 ) {
$msg = sprintf( 'Runware batch inference HTTP %d for "%s": %s', $http_code, $key, substr( $raw, 0, 300 ) );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
return [ 'error' => $msg ];
}
try {
$image_url = $this->support->extract_image_url( $raw );
} catch ( \Throwable $e ) {
$msg = sprintf( 'Runware batch inference parse error for "%s": %s', $key, $e->getMessage() );
PRAutoBlogger_Logger::instance()->error( $msg, 'runware-image-batch' );
return [ 'error' => $msg ];
}
return [
'image_url' => $image_url,
'width' => (int) $entry['width'],
'height' => (int) $entry['height'],
'model' => (string) $entry['model'],
'seed' => (int) $entry['seed'],
'started_at' => $start,
];
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
/**
* Pricing + model-id resolution helpers for the Runware image provider.
*
* Each Runware FLUX model has a flat per-image cost sourced from
* runware.ai/pricing (April 2026). Unlike OpenRouter models, Runware
* prices scale slightly with steps — the quoted rate here assumes the
* provider's default step count (4 for schnell, 28 for dev).
*
* Triggered by: PRAutoBlogger_Runware_Image_Provider, PRAutoBlogger_Runware_Image_Batch.
* Dependencies: None — pure calculation + option reads.
*
* @see class-runware-image-provider.php — Primary caller.
* @see class-open-router-image-pricing.php — Sibling pattern for OpenRouter.
*/
class PRAutoBlogger_Runware_Image_Pricing {
/**
* Flat per-image cost (USD) for each supported Runware model, April 2026.
* Update here when Runware reprices or new models launch.
*
* Runware model IDs use `family:variant@version` form. The two FLUX
* variants we support are the CEO-sanctioned defaults.
*
* @var array<string, float>
*/
private const COST_PER_IMAGE = [
// FLUX.1 schnell — 4-step distilled. Our new default (2026-04-21).
'runware:100@1' => 0.0006,
// FLUX.1 dev — 28-step full model. Higher fidelity, ~30x cost.
'runware:101@1' => 0.02,
];
/**
* Default number of inference steps per model. Runware bills per image
* at the model's standard step count; custom step counts may alter price.
*
* @var array<string, int>
*/
private const DEFAULT_STEPS = [
'runware:100@1' => 4,
'runware:101@1' => 28,
];
/**
* Resolve the model identifier from a hint, falling back to site option,
* then to the PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL constant.
*
* If the saved/default model is not a Runware id (e.g. still points at
* a Gemini slug), fall back to schnell — the provider was selected
* explicitly, so the user wants a Runware model.
*
* @param string $hint Caller-supplied model identifier (may be empty).
* @return string Runware model id.
*/
public function resolve_model( string $hint ): string {
$candidate = trim( $hint );
if ( '' === $candidate ) {
$candidate = trim( (string) get_option( 'prautoblogger_image_model', '' ) );
}
if ( '' === $candidate && defined( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL' ) ) {
$candidate = (string) PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL;
}
// If the candidate isn't a known Runware id, fall back to schnell.
if ( '' === $candidate || ! isset( self::COST_PER_IMAGE[ $candidate ] ) ) {
return 'runware:100@1';
}
return $candidate;
}
/**
* Estimate USD cost for a single image at the given model.
*
* Runware FLUX pricing is flat per image at the model's standard step
* count. Width/height are accepted for interface compatibility but do
* not affect cost for the variants we support.
*
* @param int $width Width in pixels (unused for flat pricing).
* @param int $height Height in pixels (unused for flat pricing).
* @param string $model Resolved Runware model id.
* @return float USD cost rounded to 6 decimals.
*/
public function estimate_cost( int $width, int $height, string $model ): float {
if ( isset( self::COST_PER_IMAGE[ $model ] ) ) {
return round( self::COST_PER_IMAGE[ $model ], 6 );
}
// Unknown model — log it and use a conservative $0.01 estimate.
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'No Runware pricing data for model "%s". Using $0.01 estimate.', $model ),
'runware-image'
);
return 0.01;
}
/**
* Get the default step count for a Runware model.
*
* @param string $model Resolved Runware model id.
* @return int Default steps.
*/
public function default_steps( string $model ): int {
return self::DEFAULT_STEPS[ $model ] ?? 4;
}
/**
* Get the full model cost table for admin display.
*
* @return array<string, float> Model id => cost per image in USD.
*/
public static function get_model_costs(): array {
return self::COST_PER_IMAGE;
}
}

View File

@@ -0,0 +1,294 @@
<?php
declare(strict_types=1);
/**
* Runware image provider — FLUX.1 schnell / dev via runware.ai.
*
* Default image backend as of 2026-04-21: schnell (runware:100@1) at
* ~$0.0006/image, ~2s latency. 65× cheaper than Gemini Nano Banana with
* quality that reads as "stylistic looseness" for our comic aesthetic.
*
* Triggered by: PRAutoBlogger_Image_Pipeline during content generation.
* Dependencies: Runware_Image_Support (key/parsing/backoff),
* Runware_Image_Pricing (model/cost),
* Runware_Image_Batch (parallel curl_multi), Logger.
*
* @see interface-image-provider.php — Contract.
* @see class-runware-image-batch.php — Parallel generation via curl_multi.
* @see class-runware-image-support.php — Key, response parsing, retry.
* @see class-runware-image-pricing.php — Model + cost table.
* @see class-open-router-image-provider.php — Sibling pattern for OpenRouter.
* @see ARCHITECTURE.md — External API Integrations table.
*/
class PRAutoBlogger_Runware_Image_Provider implements PRAutoBlogger_Image_Provider_Interface {
private const PROVIDER_ID = 'runware';
private const MIN_DIMENSION_PX = 64;
private const MAX_DIMENSION_PX = 2048;
/** @var PRAutoBlogger_Runware_Image_Pricing */
private PRAutoBlogger_Runware_Image_Pricing $pricing;
/** @var PRAutoBlogger_Runware_Image_Support */
private PRAutoBlogger_Runware_Image_Support $support;
public function __construct() {
$this->pricing = new PRAutoBlogger_Runware_Image_Pricing();
$this->support = new PRAutoBlogger_Runware_Image_Support();
}
/**
* {@inheritDoc}
*
* Two-step HTTP flow: POST /v1 returns a signed imageURL, then GET that
* URL for the bytes. Retries 5xx/429 on the POST with exponential
* backoff; 4xx (except 429) fail immediately. The image download GET
* is not retried — signed URLs are short-lived.
*/
public function generate_image( string $prompt, int $width, int $height, array $options = [] ): array {
$this->assert_valid_dimensions( $width, $height );
$prompt = trim( $prompt );
if ( '' === $prompt ) {
throw new \InvalidArgumentException(
esc_html__( 'Image prompt cannot be empty.', 'prautoblogger' )
);
}
$api_key = $this->support->get_api_key();
$model = $this->pricing->resolve_model( (string) ( $options['model'] ?? '' ) );
$snap_w = $this->support->snap_dimension( $width );
$snap_h = $this->support->snap_dimension( $height );
$seed = $this->support->normalize_seed( isset( $options['seed'] ) ? (int) $options['seed'] : null );
$steps = isset( $options['steps'] ) ? (int) $options['steps'] : $this->pricing->default_steps( $model );
$body = $this->build_request_body( $api_key, $prompt, $snap_w, $snap_h, $model, $seed, $steps );
$headers = [ 'Content-Type' => 'application/json' ];
$started_at = microtime( true );
$last_error = '';
for ( $attempt = 1; $attempt <= PRAUTOBLOGGER_MAX_RETRIES; $attempt++ ) {
$response = wp_remote_post( PRAutoBlogger_Runware_Image_Support::API_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_retry( $attempt, 'network', $last_error );
$this->support->backoff( $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_retry( $attempt, (string) $status, $last_error );
if ( $attempt < PRAUTOBLOGGER_MAX_RETRIES ) {
$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
$this->support->backoff( $attempt, $retry_after );
}
continue;
}
if ( $status >= 400 ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Runware image gen HTTP %d: %s', $status, substr( $raw, 0, 300 ) ),
'runware-image'
);
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware image error (HTTP %1$d): %2$s', 'prautoblogger' ),
$status,
esc_html( substr( $raw, 0, 300 ) )
)
);
}
$image_url = $this->support->extract_image_url( $raw );
$image_bytes = $this->support->download_image_bytes( $image_url );
return [
'bytes' => $image_bytes,
'mime_type' => 'image/png',
'width' => $snap_w,
'height' => $snap_h,
'model' => $model,
'seed' => $seed,
'cost_usd' => $this->pricing->estimate_cost( $snap_w, $snap_h, $model ),
'latency_ms' => (int) ( ( microtime( true ) - $started_at ) * 1000 ),
];
}
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware image gen failed after %1$d attempts. Last error: %2$s', 'prautoblogger' ),
PRAUTOBLOGGER_MAX_RETRIES,
esc_html( $last_error )
)
);
}
/**
* Generate multiple images concurrently via curl_multi.
*
* Delegates to PRAutoBlogger_Runware_Image_Batch, which fires all
* inference POSTs in parallel, then fetches the signed image URLs
* in parallel as well. Wall-clock time ≈ slowest single image.
*
* {@inheritDoc}
*/
public function generate_image_batch( array $requests ): array {
foreach ( $requests as $key => $req ) {
$this->assert_valid_dimensions( $req['width'], $req['height'] );
if ( '' === trim( $req['prompt'] ) ) {
throw new \InvalidArgumentException(
sprintf( 'Image prompt for key "%s" cannot be empty.', esc_html( $key ) )
);
}
}
$batch = new PRAutoBlogger_Runware_Image_Batch( $this->pricing, $this->support );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Starting parallel Runware image batch (%d images)', count( $requests ) ),
'runware-image'
);
return $batch->execute( $requests );
}
/** {@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 );
}
/** {@inheritDoc} */
public function get_provider_name(): string {
return self::PROVIDER_ID;
}
/**
* {@inheritDoc}
*
* Runware has no dedicated "validate key" endpoint, so we send a minimal
* authentication task and look for `taskType=authentication` in the
* response data with no accompanying errors.
*/
public function validate_credentials_detailed(): array {
try {
$api_key = $this->support->get_api_key();
} catch ( \RuntimeException $e ) {
return [ 'status' => 'error', 'message' => $e->getMessage() ];
}
$body = [
[ 'taskType' => 'authentication', 'apiKey' => $api_key ],
];
$response = wp_remote_post( PRAutoBlogger_Runware_Image_Support::API_URL, [
'timeout' => 15,
'headers' => [ 'Content-Type' => 'application/json' ],
'body' => wp_json_encode( $body ),
] );
if ( is_wp_error( $response ) ) {
return [
'status' => 'error',
'message' => __( 'Could not reach Runware API.', 'prautoblogger' ),
'debug' => $response->get_error_message(),
];
}
$status = (int) wp_remote_retrieve_response_code( $response );
$raw = (string) wp_remote_retrieve_body( $response );
if ( 200 !== $status ) {
return [
'status' => 'error',
'message' => sprintf( __( 'Runware returned HTTP %d.', 'prautoblogger' ), $status ),
'debug' => substr( $raw, 0, 200 ),
];
}
$decoded = json_decode( $raw, true );
if ( is_array( $decoded ) && isset( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
$first = $decoded['errors'][0] ?? [];
$msg = is_array( $first ) ? (string) ( $first['message'] ?? '' ) : (string) $first;
return [
'status' => 'error',
'message' => __( 'Runware rejected the API key.', 'prautoblogger' ),
'debug' => substr( $msg, 0, 200 ),
];
}
return [ 'status' => 'ok', 'message' => __( 'Runware API key valid.', 'prautoblogger' ) ];
}
/**
* Build the JSON task-array body for a single imageInference request.
*
* Runware's single endpoint consumes [auth, inference] in one POST.
*
* @param string $api_key Plaintext API key.
* @param string $prompt Full prompt text.
* @param int $width Snapped width.
* @param int $height Snapped height.
* @param string $model Resolved model id.
* @param int $seed Positive seed.
* @param int $steps Inference steps.
* @return array<int, array<string, mixed>>
*/
private function build_request_body(
string $api_key,
string $prompt,
int $width,
int $height,
string $model,
int $seed,
int $steps
): array {
return [
[
'taskType' => 'authentication',
'apiKey' => $api_key,
],
[
'taskType' => 'imageInference',
'taskUUID' => wp_generate_uuid4(),
'positivePrompt' => $prompt,
'width' => $width,
'height' => $height,
'model' => $model,
'steps' => $steps,
'numberResults' => 1,
'outputFormat' => 'PNG',
'outputType' => 'URL',
'seed' => $seed,
],
];
}
/**
* Enforce the interface's dimension contract.
*
* @param int $width Width in pixels.
* @param int $height Height in pixels.
* @throws \InvalidArgumentException If 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(
esc_html__( 'Image dimensions %1$dx%2$d outside supported range (%3$d–%4$d px).', 'prautoblogger' ),
$width, $height, self::MIN_DIMENSION_PX, self::MAX_DIMENSION_PX
)
);
}
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
/**
* Support helpers for the Runware image provider — API key lookup,
* response parsing (URL fetch), retry backoff, and logging.
*
* Split from the provider to keep both files under the 300-line cap.
*
* Triggered by: PRAutoBlogger_Runware_Image_Provider, PRAutoBlogger_Runware_Image_Batch.
* Dependencies: PRAutoBlogger_Encryption, PRAutoBlogger_Logger.
*
* @see class-runware-image-provider.php — Primary caller.
* @see class-runware-image-batch.php — Parallel caller.
*/
class PRAutoBlogger_Runware_Image_Support {
/**
* Runware task-based single endpoint. Unlike OpenRouter /chat/completions,
* Runware accepts a JSON array of tasks (auth + inference) at this URL.
*/
public const API_URL = 'https://api.runware.ai/v1';
/**
* Get the decrypted Runware API key from settings.
*
* @return string Plaintext key.
* @throws \RuntimeException If key is not configured or decrypt fails.
*/
public function get_api_key(): string {
$stored = (string) get_option( 'prautoblogger_runware_api_key', '' );
if ( '' === $stored ) {
throw new \RuntimeException(
__( 'Runware API key is not configured. Go to PRAutoBlogger → Settings → Images.', 'prautoblogger' )
);
}
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
if ( '' === $decrypted ) {
throw new \RuntimeException(
__( 'Runware API key could not be decrypted. Re-enter in Settings.', 'prautoblogger' )
);
}
return $decrypted;
}
return $stored;
}
/**
* Extract the image URL from Runware's JSON response body.
*
* The response is `{"data": [{"taskType":"imageInference","imageURL":"..."}]}`.
* We scan `data[]` for the first imageInference entry with a non-empty URL.
*
* @param string $raw Raw JSON response body from POST /v1.
* @return string The imageURL to fetch bytes from.
* @throws \RuntimeException If the response is malformed or contains no image.
*/
public function extract_image_url( string $raw ): string {
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) ) {
throw new \RuntimeException(
esc_html__( 'Runware returned invalid JSON.', 'prautoblogger' )
);
}
// Runware wraps errors in an `errors` array.
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
$first = $decoded['errors'][0] ?? [];
$msg = is_array( $first )
? (string) ( $first['message'] ?? wp_json_encode( $first ) )
: (string) $first;
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware upstream error: %s', 'prautoblogger' ),
esc_html( substr( $msg, 0, 300 ) )
)
);
}
$data = $decoded['data'] ?? [];
if ( ! is_array( $data ) || empty( $data ) ) {
throw new \RuntimeException(
esc_html__( 'Runware response contained no data.', 'prautoblogger' )
);
}
foreach ( $data as $entry ) {
if ( ! is_array( $entry ) ) {
continue;
}
if ( 'imageInference' === ( $entry['taskType'] ?? '' ) ) {
$url = (string) ( $entry['imageURL'] ?? '' );
if ( '' !== $url ) {
return $url;
}
}
}
throw new \RuntimeException(
esc_html__( 'Runware response contained no imageInference imageURL.', 'prautoblogger' )
);
}
/**
* Download the image bytes from a signed Runware delivery URL.
*
* Runware returns the image URL separately and bills the inference call;
* downloading is a regular GET against `im.runware.ai`.
*
* @param string $image_url Signed image URL from extract_image_url().
* @return string Raw PNG bytes.
* @throws \RuntimeException On HTTP error or empty body.
*/
public function download_image_bytes( string $image_url ): string {
$response = wp_remote_get( $image_url, [
'timeout' => 30,
] );
if ( is_wp_error( $response ) ) {
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware image download failed: %s', 'prautoblogger' ),
esc_html( $response->get_error_message() )
)
);
}
$status = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $status ) {
throw new \RuntimeException(
sprintf(
esc_html__( 'Runware image download HTTP %d.', 'prautoblogger' ),
$status
)
);
}
$bytes = (string) wp_remote_retrieve_body( $response );
if ( '' === $bytes ) {
throw new \RuntimeException(
esc_html__( 'Runware image download returned empty body.', 'prautoblogger' )
);
}
return $bytes;
}
/**
* Snap a target pixel dimension to Runware's FLUX constraints.
*
* FLUX.1 (schnell + dev) requires dimensions divisible by 64, in the
* range [512, 2048]. We round to the nearest multiple of 64 and clamp.
*
* @param int $px Caller-requested pixel dimension.
* @return int Snapped dimension.
*/
public function snap_dimension( int $px ): int {
$snapped = (int) round( $px / 64 ) * 64;
if ( $snapped < 512 ) {
return 512;
}
if ( $snapped > 2048 ) {
return 2048;
}
return $snapped;
}
/**
* Normalize a caller seed to Runware's constraint (must be > 0).
*
* @param int|null $seed Caller-supplied seed or null for "random".
* @return int A positive integer seed.
*/
public function normalize_seed( ?int $seed ): int {
if ( null === $seed || $seed < 1 ) {
$derived = (int) ( ( microtime( true ) * 1000 ) % 2147483647 );
return $derived > 0 ? $derived : 1;
}
return $seed;
}
/**
* Emit a WARN line for a retryable image-gen failure.
*
* @param int $attempt 1-based attempt number.
* @param string $class 'network' or HTTP status code string.
* @param string $detail Short failure detail.
*/
public function log_retry( int $attempt, string $class, string $detail ): void {
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'Runware image gen %s failure (attempt %d/%d): %s',
$class,
$attempt,
PRAUTOBLOGGER_MAX_RETRIES,
$detail
),
'runware-image'
);
}
/**
* Sleep between retries with exponential backoff.
*
* @param int $attempt 1-based attempt that just failed.
* @param int $retry_after Retry-After header value (0 if absent).
*/
public function backoff( int $attempt, int $retry_after = 0 ): void {
$delay = $retry_after > 0
? min( $retry_after, 60 )
: PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS * (int) pow( 2, $attempt - 1 );
if ( $delay > 0 ) {
sleep( $delay );
}
}
}

View File

@@ -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.8.2
* Version: 0.9.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.8.2' );
define( 'PRAUTOBLOGGER_VERSION', '0.9.0' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
@@ -54,11 +54,13 @@ 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 OpenRouter; Cloudflare is the fallback.
// Default model is Gemini 2.5 Flash Image ($0.005/image, good for comics).
// User can override via Settings → Images → Image Model.
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'openrouter' );
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'google/gemini-2.5-flash-image' );
// Default provider is Runware; OpenRouter (Gemini) + Cloudflare remain as alternates.
// 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
// override via Settings → Images → Image Model.
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER', 'runware' );
define( 'PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL', 'runware:100@1' );
// Default style suffix appended to every image-gen prompt. Updated
// 2026-04-18 v4: single-panel newspaper comic style. Caption is now

View File

@@ -0,0 +1,91 @@
<?php
/**
* Tests for PRAutoBlogger_Runware_Image_Pricing.
*
* Validates model resolution fallback logic and per-image cost estimation
* for the Runware FLUX models.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class RunwareImagePricingTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [];
return $options[ $key ] ?? $default;
} );
}
/** Empty hint resolves to the v0.9.0 default (runware schnell). */
public function test_resolve_model_empty_hint_uses_schnell_default(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$model = $pricing->resolve_model( '' );
$this->assertSame( 'runware:100@1', $model );
}
/** Explicit schnell hint resolves to itself. */
public function test_resolve_model_schnell_hint(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$this->assertSame( 'runware:100@1', $pricing->resolve_model( 'runware:100@1' ) );
}
/** Explicit dev hint resolves to itself. */
public function test_resolve_model_dev_hint(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$this->assertSame( 'runware:101@1', $pricing->resolve_model( 'runware:101@1' ) );
}
/**
* A non-Runware hint (e.g. a Gemini slug) falls back to schnell because
* the Runware provider was selected explicitly — the user wants a
* Runware model even if the admin option still holds a foreign slug.
*/
public function test_resolve_model_unknown_hint_falls_back_to_schnell(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$this->assertSame( 'runware:100@1', $pricing->resolve_model( 'google/gemini-2.5-flash-image' ) );
}
/** FLUX.1 schnell costs ~$0.0006/image. */
public function test_estimate_cost_schnell(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$cost = $pricing->estimate_cost( 1216, 640, 'runware:100@1' );
$this->assertEqualsWithDelta( 0.0006, $cost, 0.000001 );
}
/** FLUX.1 dev costs ~$0.02/image. */
public function test_estimate_cost_dev(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$cost = $pricing->estimate_cost( 1216, 640, 'runware:101@1' );
$this->assertEqualsWithDelta( 0.02, $cost, 0.000001 );
}
/** Unknown model falls back to conservative $0.01 estimate. */
public function test_estimate_cost_unknown_model(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$cost = $pricing->estimate_cost( 1216, 640, 'runware:999@1' );
$this->assertEqualsWithDelta( 0.01, $cost, 0.000001 );
}
/** Default step count: schnell=4, dev=28. */
public function test_default_steps(): void {
$pricing = new \PRAutoBlogger_Runware_Image_Pricing();
$this->assertSame( 4, $pricing->default_steps( 'runware:100@1' ) );
$this->assertSame( 28, $pricing->default_steps( 'runware:101@1' ) );
$this->assertSame( 4, $pricing->default_steps( 'runware:999@1' ) );
}
/** get_model_costs returns a non-empty map with Runware models. */
public function test_get_model_costs(): void {
$costs = \PRAutoBlogger_Runware_Image_Pricing::get_model_costs();
$this->assertIsArray( $costs );
$this->assertArrayHasKey( 'runware:100@1', $costs );
$this->assertArrayHasKey( 'runware:101@1', $costs );
}
}

View File

@@ -0,0 +1,247 @@
<?php
/**
* Tests for PRAutoBlogger_Runware_Image_Provider.
*
* Validates interface compliance, two-step POST-then-GET flow, retry
* behavior on 5xx, immediate-fail on 4xx, and dimension/prompt guards.
* All HTTP calls are mocked — no real API traffic.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class RunwareImageProviderTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = [
'prautoblogger_runware_api_key' => 'enc:fake-key',
'prautoblogger_image_model' => 'runware:100@1',
];
return $options[ $key ] ?? $default;
} );
Functions\when( 'is_wp_error' )->alias( function ( $thing ) {
return $thing instanceof \WP_Error;
} );
Functions\when( 'wp_generate_uuid4' )->justReturn( '11111111-2222-3333-4444-555555555555' );
if ( ! class_exists( 'PRAutoBlogger_Encryption' ) ) {
eval( '
class PRAutoBlogger_Encryption {
public static function is_encrypted( $v ) { return 0 === strpos( $v, "enc:" ); }
public static function decrypt( $v ) { return "rw-test-key-123"; }
public static function encrypt( $v ) { return "enc:" . $v; }
}
' );
}
}
/** Provider name is 'runware'. */
public function test_get_provider_name(): void {
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$this->assertSame( 'runware', $provider->get_provider_name() );
}
/** estimate_cost returns a positive float for schnell. */
public function test_estimate_cost_schnell(): void {
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$cost = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:100@1' ] );
$this->assertIsFloat( $cost );
$this->assertEqualsWithDelta( 0.0006, $cost, 0.000001 );
}
/** estimate_cost returns higher value for dev. */
public function test_estimate_cost_dev_higher_than_schnell(): void {
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$schnell = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:100@1' ] );
$dev = $provider->estimate_cost( 1216, 640, [ 'model' => 'runware:101@1' ] );
$this->assertGreaterThan( $schnell, $dev );
}
/** Empty prompt throws InvalidArgumentException. */
public function test_generate_image_empty_prompt_throws(): void {
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( '', 1200, 640 );
}
/** Whitespace-only prompt throws InvalidArgumentException. */
public function test_generate_image_whitespace_prompt_throws(): void {
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( " \t\n ", 1200, 640 );
}
/** Dimension below minimum throws. */
public function test_generate_image_tiny_dimensions_throws(): void {
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( 'A test prompt', 10, 10 );
}
/** Dimension above maximum throws. */
public function test_generate_image_huge_dimensions_throws(): void {
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$this->expectException( \InvalidArgumentException::class );
$provider->generate_image( 'A test prompt', 5000, 5000 );
}
/** Happy path: POST returns URL, GET returns bytes, result shape correct. */
public function test_generate_image_success(): void {
$image_url = 'https://im.runware.ai/image/abc.png';
$post_body = wp_json_encode( [
'data' => [
[ 'taskType' => 'imageInference', 'imageURL' => $image_url ],
],
] );
$fake_bytes = 'fake-png-bytes';
Functions\when( 'wp_remote_post' )->justReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $post_body,
] );
Functions\when( 'wp_remote_get' )->justReturn( [
'response' => [ 'code' => 200 ],
'body' => $fake_bytes,
] );
Functions\when( 'wp_remote_retrieve_response_code' )->alias( function ( $r ) {
return isset( $r['response']['code'] ) ? (int) $r['response']['code'] : 0;
} );
Functions\when( 'wp_remote_retrieve_body' )->alias( function ( $r ) {
return isset( $r['body'] ) ? (string) $r['body'] : '';
} );
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$result = $provider->generate_image( 'A gym bro holding a vial', 1200, 632 );
$this->assertArrayHasKey( 'bytes', $result );
$this->assertArrayHasKey( 'mime_type', $result );
$this->assertArrayHasKey( 'width', $result );
$this->assertArrayHasKey( 'height', $result );
$this->assertArrayHasKey( 'model', $result );
$this->assertArrayHasKey( 'seed', $result );
$this->assertArrayHasKey( 'cost_usd', $result );
$this->assertArrayHasKey( 'latency_ms', $result );
$this->assertSame( $fake_bytes, $result['bytes'] );
$this->assertSame( 'image/png', $result['mime_type'] );
// Snapped to multiples of 64: 1200 → 1216, 632 → 640.
$this->assertSame( 1216, $result['width'] );
$this->assertSame( 640, $result['height'] );
$this->assertSame( 'runware:100@1', $result['model'] );
$this->assertGreaterThan( 0, $result['seed'] );
}
/** 4xx (non-429) fails immediately with RuntimeException. */
public function test_generate_image_4xx_throws_immediately(): void {
Functions\when( 'wp_remote_post' )->justReturn( [
'response' => [ 'code' => 401 ],
'headers' => [],
'body' => '{"errors":[{"message":"invalid key"}]}',
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 401 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"invalid key"}]}' );
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'HTTP 401' );
$provider->generate_image( 'A test prompt', 1200, 632 );
}
/** 5xx triggers retry loop and eventually throws after PRAUTOBLOGGER_MAX_RETRIES. */
public function test_generate_image_5xx_retries_then_throws(): void {
$call_count = 0;
Functions\when( 'wp_remote_post' )->alias( function () use ( &$call_count ) {
$call_count++;
return [
'response' => [ 'code' => 503 ],
'headers' => [],
'body' => '{"errors":[{"message":"temporarily unavailable"}]}',
];
} );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 503 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"temporarily unavailable"}]}' );
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
$provider = new \PRAutoBlogger_Runware_Image_Provider();
try {
$provider->generate_image( 'A test prompt', 1200, 632 );
$this->fail( 'Expected RuntimeException after max retries' );
} catch ( \RuntimeException $e ) {
$this->assertSame(
PRAUTOBLOGGER_MAX_RETRIES,
$call_count,
'Provider must attempt exactly PRAUTOBLOGGER_MAX_RETRIES times before giving up.'
);
}
}
/** errors[] in a 200 body is treated as an upstream error. */
public function test_generate_image_errors_in_200_body_throws(): void {
$body = wp_json_encode( [
'errors' => [ [ 'message' => 'moderation rejected' ] ],
] );
Functions\when( 'wp_remote_post' )->justReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $body,
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( $body );
Functions\when( 'wp_remote_retrieve_header' )->justReturn( '' );
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$this->expectException( \RuntimeException::class );
$provider->generate_image( 'A test prompt', 1200, 632 );
}
/** validate_credentials_detailed returns ok for a 200 response with no errors. */
public function test_validate_credentials_detailed_ok(): void {
Functions\when( 'wp_remote_post' )->justReturn( [
'response' => [ 'code' => 200 ],
'body' => '{"data":[{"taskType":"authentication"}]}',
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"data":[{"taskType":"authentication"}]}' );
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$result = $provider->validate_credentials_detailed();
$this->assertSame( 'ok', $result['status'] );
}
/** validate_credentials_detailed returns error when key missing. */
public function test_validate_credentials_detailed_missing_key(): void {
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
return $default;
} );
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$result = $provider->validate_credentials_detailed();
$this->assertSame( 'error', $result['status'] );
}
/** validate_credentials_detailed returns error when response contains errors[]. */
public function test_validate_credentials_detailed_rejected_key(): void {
Functions\when( 'wp_remote_post' )->justReturn( [
'response' => [ 'code' => 200 ],
'body' => '{"errors":[{"message":"invalid key"}]}',
] );
Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 );
Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"errors":[{"message":"invalid key"}]}' );
$provider = new \PRAutoBlogger_Runware_Image_Provider();
$result = $provider->validate_credentials_detailed();
$this->assertSame( 'error', $result['status'] );
}
}

View File

@@ -0,0 +1,184 @@
<?php
/**
* Tests for PRAutoBlogger_Runware_Image_Support.
*
* Covers response parsing (URL extraction), dimension snapping to FLUX
* constraints, seed normalization, and API-key lookup.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class RunwareImageSupportTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
Functions\when( 'esc_html' )->returnArg();
Functions\when( 'esc_html__' )->returnArg();
Functions\when( '__' )->returnArg();
if ( ! class_exists( 'PRAutoBlogger_Encryption' ) ) {
eval( '
class PRAutoBlogger_Encryption {
public static function is_encrypted( $v ) { return 0 === strpos( $v, "enc:" ); }
public static function decrypt( $v ) { return "rw-test-key-123"; }
public static function encrypt( $v ) { return "enc:" . $v; }
}
' );
}
}
/** A well-formed imageInference entry returns its imageURL. */
public function test_extract_image_url_valid_response(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$raw = wp_json_encode( [
'data' => [
[
'taskType' => 'imageInference',
'imageURL' => 'https://im.runware.ai/image/abc123.png',
],
],
] );
$this->assertSame(
'https://im.runware.ai/image/abc123.png',
$support->extract_image_url( $raw )
);
}
/** An errors[] array in the response causes a RuntimeException. */
public function test_extract_image_url_error_response_throws(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$raw = wp_json_encode( [
'errors' => [
[ 'message' => 'invalid api key' ],
],
] );
$this->expectException( \RuntimeException::class );
$this->expectExceptionMessage( 'invalid api key' );
$support->extract_image_url( $raw );
}
/** Malformed JSON throws RuntimeException. */
public function test_extract_image_url_invalid_json_throws(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->expectException( \RuntimeException::class );
$support->extract_image_url( 'not json at all' );
}
/** Data array present but no imageInference entry throws. */
public function test_extract_image_url_no_inference_entry_throws(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$raw = wp_json_encode( [
'data' => [
[ 'taskType' => 'authentication' ],
],
] );
$this->expectException( \RuntimeException::class );
$support->extract_image_url( $raw );
}
/** imageInference present but empty imageURL throws. */
public function test_extract_image_url_empty_url_throws(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$raw = wp_json_encode( [
'data' => [
[ 'taskType' => 'imageInference', 'imageURL' => '' ],
],
] );
$this->expectException( \RuntimeException::class );
$support->extract_image_url( $raw );
}
/** snap_dimension rounds to nearest multiple of 64. */
public function test_snap_dimension_rounds_to_64(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->assertSame( 1216, $support->snap_dimension( 1200 ) );
$this->assertSame( 640, $support->snap_dimension( 632 ) );
$this->assertSame( 1024, $support->snap_dimension( 1024 ) );
$this->assertSame( 1024, $support->snap_dimension( 1050 ) );
}
/** snap_dimension clamps to minimum 512. */
public function test_snap_dimension_clamps_min(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->assertSame( 512, $support->snap_dimension( 64 ) );
$this->assertSame( 512, $support->snap_dimension( 200 ) );
}
/** snap_dimension clamps to maximum 2048. */
public function test_snap_dimension_clamps_max(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->assertSame( 2048, $support->snap_dimension( 4096 ) );
$this->assertSame( 2048, $support->snap_dimension( 2500 ) );
}
/** A valid positive seed passes through unchanged. */
public function test_normalize_seed_positive_passthrough(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->assertSame( 42, $support->normalize_seed( 42 ) );
$this->assertSame( 1234567, $support->normalize_seed( 1234567 ) );
}
/** Null seed derives a positive integer. */
public function test_normalize_seed_null_derives_positive(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$seed = $support->normalize_seed( null );
$this->assertGreaterThan( 0, $seed );
}
/** Zero seed is replaced with a derived positive integer. */
public function test_normalize_seed_zero_replaced(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$seed = $support->normalize_seed( 0 );
$this->assertGreaterThan( 0, $seed );
}
/** Negative seed is replaced with a derived positive integer. */
public function test_normalize_seed_negative_replaced(): void {
$support = new \PRAutoBlogger_Runware_Image_Support();
$seed = $support->normalize_seed( -5 );
$this->assertGreaterThan( 0, $seed );
}
/** Missing API key option throws RuntimeException. */
public function test_get_api_key_missing_throws(): void {
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
return $default;
} );
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->expectException( \RuntimeException::class );
$support->get_api_key();
}
/** Encrypted key is decrypted on read. */
public function test_get_api_key_decrypts_encrypted(): void {
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
if ( 'prautoblogger_runware_api_key' === $key ) {
return 'enc:stored-ciphertext';
}
return $default;
} );
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->assertSame( 'rw-test-key-123', $support->get_api_key() );
}
/** A plaintext key (pre-migration) passes through unchanged. */
public function test_get_api_key_plaintext_passthrough(): void {
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
if ( 'prautoblogger_runware_api_key' === $key ) {
return 'rw-plain-key';
}
return $default;
} );
$support = new \PRAutoBlogger_Runware_Image_Support();
$this->assertSame( 'rw-plain-key', $support->get_api_key() );
}
}

View File

@@ -18,7 +18,7 @@ class ImageModelRegistryTest extends BaseTestCase {
/**
* Every entry returned by get_models() must carry a non-empty id and
* a provider of 'openrouter' or 'cloudflare'. If this breaks, the
* a provider of 'runware', 'openrouter', or 'cloudflare'. 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 +31,7 @@ class ImageModelRegistryTest extends BaseTestCase {
$this->assertArrayHasKey( 'provider', $model );
$this->assertContains(
$model['provider'],
[ 'openrouter', 'cloudflare' ],
[ 'runware', 'openrouter', 'cloudflare' ],
sprintf( 'Unexpected provider %s for model %s', (string) $model['provider'], (string) $model['id'] )
);
}
@@ -52,6 +52,22 @@ class ImageModelRegistryTest extends BaseTestCase {
);
}
/**
* Runware model ids (added v0.9.0) resolve to the 'runware' provider.
* This test pins the v0.9.0 default flip so a registry regression
* that loses the Runware entries fails CI loudly.
*/
public function test_provider_for_runware_models(): void {
$this->assertSame(
'runware',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:100@1' )
);
$this->assertSame(
'runware',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:101@1' )
);
}
/**
* Unknown model ids return an empty string so callers can distinguish
* "not in registry" from a valid provider and show a settings error.