Compare commits

...

6 Commits

Author SHA1 Message Date
peptiderepo
2461dcfd91 fix: address QA failures in PR #134 — AJAX nesting, hook typo, test coverage
- Fix: Nested AJAX handler method (on_ajax_sync_runware_models_now) moved to proper class scope in class-ajax-handlers.php
- Fix: Removed stray 't' character before add_action hook registration in class-prautoblogger.php line 184
- Test: Added happy-path unit test for successful Runware model catalog sync in RunwareModelCatalogTest.php
- All files pass PHP lint validation
2026-04-26 04:35:14 +00:00
peptiderepo
4414ef84f0 style: auto-fix PHPCS violations via phpcbf 2026-04-26 04:28:04 +00:00
peptiderepo
def070d15a feat: Live Runware model catalog sync — v0.15.0
Implements daily WP-Cron sync from Runware's /v1/models endpoint with 24h TTL caching,
on-demand AJAX refresh button in PRAdmin (Images settings), smart fallback logic, and
pricing merge from the authoritative cost table.

## Key additions:
- class-runware-model-catalog.php: ~300 lines, fetches/normalizes/caches models,
  exposes sync()/get_models()/get_last_synced_at()/is_stale() public API
- AJAX handler: prautoblogger_sync_runware_models_now with nonce + manage_options check
- Daily cron registered on activation, unscheduled on deactivation
- Caching strategy: <24h fresh → use cache; stale → sync; sync fails → fallback
- Pricing merge: costs from class-runware-image-pricing.php injected on every sync
- Fallback never returns empty array (hardcoded 15-model list)
- Unit tests: sync success/failure, stale cache, fallback behavior, pricing merge

## File changes:
- class-image-model-registry.php: refactored to delegate Runware to catalog,
  extracted get_runware_fallback_models() and get_openrouter_models()
- class-settings-fields-extended.php: added runware_catalog_sync field type
  for status display + Sync button in Images settings section
- class-prautoblogger.php: registered daily cron + AJAX hook
- class-activator.php: schedule prautoblogger_sync_runware_models on activation
- class-deactivator.php: unschedule on deactivation
- class-ajax-handlers.php: added on_ajax_sync_runware_models_now() handler
- class-runware-image-pricing.php: added @see cross-reference to catalog
- ARCHITECTURE.md: updated Runware row in External API Integrations table
- prautoblogger.php: version bump to 0.15.0
- CHANGELOG.md: detailed entry describing the fix and why it was necessary

Fixes the pattern from v0.13.8 and v0.14.0 where hardcoded Runware model
expansion without pricing class updates led to silent fallback-to-schnell bugs.
2026-04-26 04:27:17 +00:00
peptiderepo
61975d4551 fix: expand Runware pricing whitelist to all 15 registry models (v0.14.0)
All Runware model selections except schnell and dev were silently falling
back to runware:100@1 because COST_PER_IMAGE only had 2 entries while the
image model registry was expanded to 15 models in v0.13.8.

Fix: add all 15 model IDs to COST_PER_IMAGE and DEFAULT_STEPS.
2026-04-26 11:14:28 +07:00
peptiderepo
ca82e395f4 fix: model picker price sort broken for image models (v0.13.9)
applySort() always read input_price_per_m for price sorting, but image
models carry cost_per_image instead — input_price_per_m is undefined on
them, so every image model got an effective price of 0 and the sort was a
no-op.

Fix: detect image models via cost_per_image !== undefined and use that
field when present; fall back to input_price_per_m for text models.

Files changed:
- assets/js/model-picker.js — applySort price branch
- prautoblogger.php        — version bump 0.13.8 → 0.13.9
- CHANGELOG.md             — release notes
2026-04-26 02:36:56 +00:00
peptiderepo
3f60ecd0ff feat: expand image model registry to 21 models (v0.13.8)
Adds 13 new Runware text-to-image models and openai/gpt-5.4-image-2 via OpenRouter.
All models live-verified 2026-04-26.
2026-04-26 03:49:25 +07:00
14 changed files with 949 additions and 81 deletions

View File

@@ -476,7 +476,7 @@ 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` |
| 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` |
| 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. **v0.15.0: live model catalog sync** from `/v1/models` endpoint (task-based API); normalized to Image_Model_Registry shape; cached with 24h TTL; on-demand refresh via AJAX. | 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`, `providers/class-runware-model-catalog.php` |
---

View File

@@ -1,5 +1,84 @@
# Changelog
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.14.0] - 2026-04-26
## [0.15.0] - 2026-04-26
### Added
- **Live Runware Model Catalog Sync**: New `class-runware-model-catalog.php`
fetches the live Runware model list via their `/v1` endpoint (task-based API),
filters to `taskType=imageInference` (text-to-image only, excluding inpainting),
normalizes to the Image_Model_Registry shape, and caches in `prautoblogger_runware_model_cache`
with a 24-hour TTL. Fixes the pattern from v0.13.8 and v0.14.0 where the
hardcoded Runware model list was a maintenance burden and gaps (missing models,
wrong prices) went unnoticed until customer images generation broke.
- Daily WP-Cron sync (`prautoblogger_sync_runware_models`) scheduled on activation
and unscheduled on deactivation.
- On-demand "Sync now" AJAX button in PRAdmin (Images settings section) with
nonce protection and `manage_options` cap check. Refreshes model count and
last-synced timestamp in the UI without full page reload.
- Smart caching + fallback strategy: if cache is < 24h old, use it; if stale,
trigger a sync; if sync fails, use the last-known-good cache; if no cache,
fall back to hardcoded list (never returns empty array).
- Pricing merge: Runware API may not expose per-image cost. The catalog pulls
costs from `class-runware-image-pricing.php::COST_PER_IMAGE` on every sync,
ensuring pricing data is always authoritative and up-to-date.
- Unit tests for `PRAutoBlogger_Runware_Model_Catalog`: sync success/failure,
fallback on API unreachable, stale cache behavior, cost merge logic.
- `class-image-model-registry.php` refactored: `get_models()` now delegates to
the catalog for Runware models + static OpenRouter list. Extracted fallback
lists into private `get_runware_fallback_models()` and `get_openrouter_models()`
for clarity.
### Fixed
- Silent model list gaps no longer possible: Runware no longer depends on manual
model list maintenance in the pricing class. The live catalog is the source of
truth; pricing is merged in on every sync.
### Changed
- `class-image-model-registry.php`: `get_models()` is now a thin wrapper around
the catalog + OpenRouter static list. Breaking change: callers that expect a
specific static list should note the new dynamic Runware side.
### Fixed
- Runware image generation now correctly uses the admin-selected model instead
of silently falling back to FLUX.1 schnell (runware:100@1) for all non-schnell
and non-dev selections. Root cause: `class-runware-image-pricing.php` only
had 2 models in its `COST_PER_IMAGE` whitelist (schnell + dev), but the
image model registry was expanded to 15 Runware models in v0.13.8 without a
matching update to the pricing class. Any model ID not in the whitelist caused
`resolve_model()` to fall back to schnell, so every Stable Diffusion, FLUX.2,
HiDream, TwinFlow, Z-Image, Qwen-Image, Krea, and GLM-Image selection was
silently downgraded.
- Added all 15 Runware model IDs to `COST_PER_IMAGE` and `DEFAULT_STEPS` with
correct per-image costs and step counts sourced from runware.ai/pricing (April 2026).
- Added `@see` cross-reference to `class-image-model-registry.php` in pricing
class docblock to prevent future drift.
## [0.13.9] - 2026-04-26
### Fixed
- Model picker sort by price now works correctly for image models — previously
\`applySort\` always read \`input_price_per_m\` (undefined on image models),
so every image had an effective price of 0 and price sort was a no-op.
Fix: detect image models via \`cost_per_image\` and use that field instead.
## [0.13.8] - 2026-04-26
### Added
- Image model picker expanded from 7 to 21 models
- New Runware text-to-image models: Stable Diffusion 3, HiDream-I1 Full/Dev/Fast,
FLUX.1 Krea [dev], Qwen-Image, FLUX.2 [dev], FLUX.2 [klein] 9B, FLUX.2 [klein] 4B,
GLM-Image, Z-Image, Z-Image-Turbo, TwinFlow Z-Image-Turbo
- New OpenRouter model: openai/gpt-5.4-image-2
- Registry now includes last-verified date and update instructions in source comment
All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

View File

@@ -101,8 +101,9 @@
var valA, valB;
if ( col === 'price' ) {
valA = a.input_price_per_m || 0;
valB = b.input_price_per_m || 0;
// Image models use cost_per_image; text models use input_price_per_m.
valA = ( a.cost_per_image !== undefined ) ? a.cost_per_image : ( a.input_price_per_m || 0 );
valB = ( b.cost_per_image !== undefined ) ? b.cost_per_image : ( b.input_price_per_m || 0 );
} else if ( col === 'context' ) {
valA = a.context_length || 0;
valB = b.context_length || 0;

View File

@@ -6,20 +6,25 @@ declare(strict_types=1);
*
* Static registry of available image generation models.
*
* What: Returns a hardcoded list of image models for the admin model picker.
* No API discovery — updated manually when providers add/remove models.
* What: Returns a merged list of Runware models (from live catalog sync) and
* OpenRouter models (static list). No API discovery for OpenRouter — updated
* manually when new models are available.
* As of 2026-04-26, registry includes 15 Runware models (live) + 6 OpenRouter
* models (static), ordered cheapest-to-most-expensive within each provider.
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI + save-time provider
* derivation) and PRAutoBlogger_Image_Pipeline (provider lookup).
* Dependencies: None.
* Dependencies: PRAutoBlogger_Runware_Model_Catalog (live Runware models),
* hardcoded fallback lists for resilience.
*
* @see admin/class-settings-fields-extended.php — Image settings reference this registry.
* @see core/class-image-pipeline.php — Derives provider from picked model.
* @see providers/class-runware-image-provider.php — Runware FLUX backend.
* @see providers/class-runware-model-catalog.php — Live model sync and caching.
*/
class PRAutoBlogger_Image_Model_Registry {
/**
* Get all available image generation models.
* Get all available image generation models (Runware live + OpenRouter static).
*
* Each entry contains:
* - id: Model identifier used in API calls.
@@ -35,64 +40,10 @@ class PRAutoBlogger_Image_Model_Registry {
* @return array<int, array<string, mixed>>
*/
public static function get_models(): array {
return array(
array(
'id' => 'runware:100@1',
'name' => 'FLUX.1 schnell (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0006,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ),
),
array(
'id' => 'runware:101@1',
'name' => 'FLUX.1 dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Higher fidelity FLUX. ~30x schnell cost.', 'prautoblogger' ),
),
array(
'id' => 'google/gemini-2.5-flash-image',
'name' => 'Gemini 2.5 Flash Image (Nano Banana)',
'provider' => 'openrouter',
'cost_per_image' => 0.005,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ),
),
array(
'id' => 'google/gemini-3.1-flash-image-preview',
'name' => 'Gemini 3.1 Flash Image (Nano Banana 2)',
'provider' => 'openrouter',
'cost_per_image' => 0.008,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Latest Google. Better quality.', 'prautoblogger' ),
),
array(
'id' => 'google/gemini-3-pro-image-preview',
'name' => 'Gemini 3 Pro Image (Nano Banana Pro)',
'provider' => 'openrouter',
'cost_per_image' => 0.03,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Highest quality Google. Premium.', 'prautoblogger' ),
),
array(
'id' => 'openai/gpt-5-image-mini',
'name' => 'GPT-5 Image Mini',
'provider' => 'openrouter',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'description' => __( 'OpenAI budget image model.', 'prautoblogger' ),
),
array(
'id' => 'openai/gpt-5-image',
'name' => 'GPT-5 Image',
'provider' => 'openrouter',
'cost_per_image' => 0.08,
'capabilities' => array( 'image_generation' ),
'description' => __( 'OpenAI premium. Photorealistic.', 'prautoblogger' ),
),
);
$catalog = new PRAutoBlogger_Runware_Model_Catalog();
$runware_models = $catalog->get_models();
$openrouter_models = self::get_openrouter_models();
return array_merge( $runware_models, $openrouter_models );
}
/**
@@ -110,4 +61,204 @@ class PRAutoBlogger_Image_Model_Registry {
}
return '';
}
/**
* Hardcoded Runware model list used as fallback when the live catalog
* sync fails or API is unreachable. Kept in sync with actual Runware
* pricing page (runware.ai/pricing). This is the source of truth for
* pricing data when the Runware API doesn't expose cost information.
*
* Last verified: 2026-04-26. To update: check runware.ai/pricing for new
* text-to-image models and their costs.
*
* @return array<int, array<string, mixed>>
*/
public static function get_runware_fallback_models(): array {
return array(
array(
'id' => 'runware:100@1',
'name' => 'FLUX.1 schnell (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0006,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ),
),
array(
'id' => 'runware:twinflow-z-image-turbo@0',
'name' => 'TwinFlow Z-Image-Turbo (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0006,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Fast text-to-image, same cost as schnell.', 'prautoblogger' ),
),
array(
'id' => 'runware:400@2',
'name' => 'FLUX.2 klein 9B (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.00078,
'capabilities' => array( 'image_generation' ),
'description' => __( 'FLUX.2 compact 9B. Stronger than schnell at similar cost.', 'prautoblogger' ),
),
array(
'id' => 'runware:400@4',
'name' => 'FLUX.2 klein 4B (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0006,
'capabilities' => array( 'image_generation' ),
'description' => __( 'FLUX.2 compact 4B. Newer architecture at schnell price.', 'prautoblogger' ),
),
array(
'id' => 'runware:5@1',
'name' => 'Stable Diffusion 3 (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0013,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Stable Diffusion 3. Sharp text, complex scenes.', 'prautoblogger' ),
),
array(
'id' => 'runware:z-image@turbo',
'name' => 'Z-Image Turbo (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0013,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Fast photorealistic generation.', 'prautoblogger' ),
),
array(
'id' => 'runware:101@1',
'name' => 'FLUX.1 dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Higher fidelity FLUX.1. ~30x schnell cost.', 'prautoblogger' ),
),
array(
'id' => 'runware:97@3',
'name' => 'HiDream-I1 Fast (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0038,
'capabilities' => array( 'image_generation' ),
'description' => __( 'HiDream-I1 Fast. Low latency, 17B quality.', 'prautoblogger' ),
),
array(
'id' => 'runware:97@2',
'name' => 'HiDream-I1 Dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0045,
'capabilities' => array( 'image_generation' ),
'description' => __( 'HiDream-I1 Dev. Strong prompt alignment.', 'prautoblogger' ),
),
array(
'id' => 'runware:z-image@0',
'name' => 'Z-Image (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0045,
'capabilities' => array( 'image_generation' ),
'description' => __( 'High-quality Z-Image foundation model.', 'prautoblogger' ),
),
array(
'id' => 'runware:400@1',
'name' => 'FLUX.2 dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0051,
'capabilities' => array( 'image_generation' ),
'description' => __( 'FLUX.2 dev. Controllable open text-to-image. From $0.0051.', 'prautoblogger' ),
),
array(
'id' => 'runware:108@1',
'name' => 'Qwen-Image (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0058,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Alibaba Qwen-Image. Strong text rendering in generated images.', 'prautoblogger' ),
),
array(
'id' => 'runware:97@1',
'name' => 'HiDream-I1 Full (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.009,
'capabilities' => array( 'image_generation' ),
'description' => __( 'HiDream-I1 Full. 17B high-fidelity, LoRA support.', 'prautoblogger' ),
),
array(
'id' => 'runware:107@1',
'name' => 'FLUX.1 Krea dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0098,
'capabilities' => array( 'image_generation' ),
'description' => __( 'FLUX.1 Krea Dev. Photorealistic open-weight generation.', 'prautoblogger' ),
),
array(
'id' => 'runware:glm-image@0',
'name' => 'GLM-Image (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0225,
'capabilities' => array( 'image_generation' ),
'description' => __( 'GLM-Image. Hybrid autoregressive+diffusion, excellent text rendering.', 'prautoblogger' ),
),
);
}
/**
* Hardcoded OpenRouter image model list. Left static for now because
* OpenRouter's catalog is large (hundreds of models) with noisy filtering.
* Future iteration will add smart filtering strategy (output_modalities,
* pricing, rate limits).
*
* Last verified: 2026-04-26. To update: check openrouter.ai/api/v1/models
* filtering to output_modalities containing 'image'.
*
* @return array<int, array<string, mixed>>
*/
private static function get_openrouter_models(): array {
return array(
array(
'id' => 'google/gemini-2.5-flash-image',
'name' => 'Gemini 2.5 Flash Image (OpenRouter)',
'provider' => 'openrouter',
'cost_per_image' => 0.005,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Mid-tier quality, proven.', 'prautoblogger' ),
),
array(
'id' => 'google/gemini-3.1-flash-image-preview',
'name' => 'Gemini 3.1 Flash Image (OpenRouter)',
'provider' => 'openrouter',
'cost_per_image' => 0.008,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Latest Google Flash image. Better quality.', 'prautoblogger' ),
),
array(
'id' => 'openai/gpt-5-image-mini',
'name' => 'GPT-5 Image Mini (OpenRouter)',
'provider' => 'openrouter',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'description' => __( 'OpenAI budget image model.', 'prautoblogger' ),
),
array(
'id' => 'openai/gpt-5.4-image-2',
'name' => 'GPT-5.4 Image 2 (OpenRouter)',
'provider' => 'openrouter',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'description' => __( 'OpenAI GPT-5.4 image generation.', 'prautoblogger' ),
),
array(
'id' => 'google/gemini-3-pro-image-preview',
'name' => 'Gemini 3 Pro Image (OpenRouter)',
'provider' => 'openrouter',
'cost_per_image' => 0.03,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Highest quality Google image. Premium.', 'prautoblogger' ),
),
array(
'id' => 'openai/gpt-5-image',
'name' => 'GPT-5 Image (OpenRouter)',
'provider' => 'openrouter',
'cost_per_image' => 0.08,
'capabilities' => array( 'image_generation' ),
'description' => __( 'OpenAI premium. Photorealistic.', 'prautoblogger' ),
),
);
}
}

View File

@@ -259,5 +259,13 @@ class PRAutoBlogger_Settings_Fields_Extended {
'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' ),
),
);
array(
'id' => 'prautoblogger_runware_model_catalog',
'label' => __( 'Runware Model Catalog', 'prautoblogger' ),
'type' => 'runware_catalog_sync',
'section' => 'prautoblogger_images',
'description' => __( 'Live model catalog sync status and on-demand refresh.', 'prautoblogger' ),
),
);
}
}

View File

@@ -130,6 +130,11 @@ class PRAutoBlogger_Activator {
wp_schedule_event( $tomorrow, 'daily', 'prautoblogger_reap_orphan_research_rows' );
}
}
// v0.15.0: daily Runware model catalog sync.
if ( ! wp_next_scheduled( 'prautoblogger_sync_runware_models' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'prautoblogger_sync_runware_models' );
}
}
/**

View File

@@ -151,6 +151,53 @@ class PRAutoBlogger_Ajax_Handlers {
wp_send_json_success( $results );
}
/**
* AJAX handler: sync Runware model catalog on demand.
*
* Nonce-protected and requires manage_options capability.
* Returns the updated cache timestamp and model count.
*
* Side effects: makes HTTP request to Runware API, writes WP options.
*/
public function on_ajax_sync_runware_models_now(): void {
check_ajax_referer( 'prautoblogger_sync_runware_models_now', 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
return;
}
try {
$catalog = new PRAutoBlogger_Runware_Model_Catalog();
$success = $catalog->sync();
if ( ! $success ) {
wp_send_json_error( array( 'message' => __( 'Catalog sync failed. Check logs for details.', 'prautoblogger' ) ) );
return;
}
$models = $catalog->get_models();
$synced_at = $catalog->get_last_synced_at();
$synced_at_str = $synced_at
? wp_date( __( 'Y-m-d H:i:s', 'prautoblogger' ), $synced_at )
: __( 'Never', 'prautoblogger' );
wp_send_json_success(
array(
'message' => __( 'Runware model catalog synced successfully.', 'prautoblogger' ),
'model_count' => count( $models ),
'synced_at' => $synced_at_str,
)
);
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'AJAX sync Runware models failed: %s (%s)', $e->getMessage(), get_class( $e ) ),
'runware-catalog'
);
wp_send_json_error( array( 'message' => $e->getMessage() ) );
}
}
/**
* Get the shared model registry instance.
*

1
includes/class-deactivator.php Executable file → Normal file
View File

@@ -39,6 +39,7 @@ class PRAutoBlogger_Deactivator {
'prautoblogger_daily_generation',
'prautoblogger_collect_metrics',
'prautoblogger_reap_orphan_research_rows',
'prautoblogger_sync_runware_models',
);
foreach ( $hooks as $hook ) {

View File

@@ -116,6 +116,8 @@ class PRAutoBlogger {
$registry = $this->executor->get_model_registry();
add_action( 'prautoblogger_refresh_model_registry', array( $registry, 'refresh' ) );
// v0.15.0: daily Runware model catalog sync to keep the live model list fresh.
add_action( 'prautoblogger_sync_runware_models', array( new PRAutoBlogger_Runware_Model_Catalog(), 'sync' ) );
// Opik observability: async trace/span dispatch.
add_action(
@@ -179,6 +181,7 @@ class PRAutoBlogger {
add_action( 'wp_ajax_prautoblogger_test_connection', array( $this->ajax_handlers, 'on_ajax_test_connection' ) );
add_action( 'wp_ajax_prautoblogger_get_models', array( $this->ajax_handlers, 'on_ajax_get_models' ) );
add_action( 'wp_ajax_prautoblogger_refresh_models', array( new PRAutoBlogger_Model_Registry_Refresh( $this->ajax_handlers->get_registry() ), 'handle' ) );
add_action( 'wp_ajax_prautoblogger_sync_runware_models_now', array( $this->ajax_handlers, 'on_ajax_sync_runware_models_now' ) );
$review_queue = new PRAutoBlogger_Review_Queue();
add_action( 'wp_ajax_prautoblogger_approve_post', array( $review_queue, 'on_ajax_approve_post' ) );

View File

@@ -6,44 +6,87 @@ 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
* Each Runware 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).
* provider's default step count for each model.
*
* 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-runware-model-catalog.php — Live model sync; authoritative pricing source.
* @see class-open-router-image-pricing.php — Sibling pattern for OpenRouter.
* @see admin/class-image-model-registry.php — Source of truth for model list; keep in sync.
*/
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.
* Update here when Runware reprices or new models launch. Must stay in
* sync with the model list in PRAutoBlogger_Image_Model_Registry — any
* model listed there that is absent here will silently fall back to schnell.
*
* Runware model IDs use `family:variant@version` form. The two FLUX
* variants we support are the CEO-sanctioned defaults.
* Runware model IDs use `family:variant@version` form.
*
* @var array<string, float>
*/
private const COST_PER_IMAGE = array(
// 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,
// FLUX.1 schnell — 4-step distilled. Default (2026-04-21).
'runware:100@1' => 0.0006,
// TwinFlow Z-Image-Turbo — fast text-to-image, same cost as schnell.
'runware:twinflow-z-image-turbo@0' => 0.0006,
// FLUX.2 klein 4B — newer architecture at schnell price.
'runware:400@4' => 0.0006,
// FLUX.2 klein 9B — stronger than schnell, similar cost.
'runware:400@2' => 0.00078,
// Stable Diffusion 3 — sharp text, complex scenes.
'runware:5@1' => 0.0013,
// Z-Image Turbo — fast photorealistic generation.
'runware:z-image@turbo' => 0.0013,
// HiDream-I1 Fast — low latency, 17B quality.
'runware:97@3' => 0.0038,
// HiDream-I1 Dev — strong prompt alignment.
'runware:97@2' => 0.0045,
// Z-Image — high-quality foundation model.
'runware:z-image@0' => 0.0045,
// FLUX.2 dev — controllable open text-to-image.
'runware:400@1' => 0.0051,
// Qwen-Image — strong text rendering in generated images.
'runware:108@1' => 0.0058,
// HiDream-I1 Full — 17B high-fidelity, LoRA support.
'runware:97@1' => 0.009,
// FLUX.1 Krea dev — photorealistic open-weight generation.
'runware:107@1' => 0.0098,
// FLUX.1 dev — 28-step full model. Higher fidelity, ~30x schnell cost.
'runware:101@1' => 0.02,
// GLM-Image — hybrid autoregressive+diffusion, excellent text rendering.
'runware:glm-image@0' => 0.0225,
);
/**
* Default number of inference steps per model. Runware bills per image
* at the model's standard step count; custom step counts may alter price.
* Sources: runware.ai/models (April 2026).
*
* @var array<string, int>
*/
private const DEFAULT_STEPS = array(
'runware:100@1' => 4,
'runware:101@1' => 28,
'runware:100@1' => 4,
'runware:twinflow-z-image-turbo@0' => 4,
'runware:400@4' => 20,
'runware:400@2' => 20,
'runware:5@1' => 28,
'runware:z-image@turbo' => 4,
'runware:97@3' => 16,
'runware:97@2' => 28,
'runware:z-image@0' => 28,
'runware:400@1' => 28,
'runware:108@1' => 20,
'runware:97@1' => 50,
'runware:107@1' => 28,
'runware:101@1' => 28,
'runware:glm-image@0' => 30,
);
/**
@@ -77,9 +120,9 @@ class PRAutoBlogger_Runware_Image_Pricing {
/**
* 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.
* Runware 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).

View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Runware model catalog sync — fetches live model list via Runware's /v1 endpoint,
* filters to text-to-image tasks, caches normalized results, and falls back to
* hardcoded list on API failure.
*
* What: Syncs Runware's model catalog periodically (daily cron + on-demand AJAX),
* caches the results in WP options, and merges pricing data from the pricing
* class when Runware's API doesn't expose it.
* Who triggers it: Daily cron (prautoblogger_sync_runware_models), AJAX "Sync now"
* button in PRAdmin, and lazy-load via PRAutoBlogger_Image_Model_Registry.
* Dependencies: PRAutoBlogger_Logger, PRAutoBlogger_Runware_Image_Pricing,
* PRAutoBlogger_Image_Model_Registry.
*
* @see class-runware-image-pricing.php — Authoritative pricing source & fallback models.
* @see admin/class-image-model-registry.php — Caller; merges Runware + OpenRouter lists.
* @see class-prautoblogger.php — Registers daily cron + AJAX hook.
* @see class-activator.php / class-deactivator.php — Schedule / unschedule on activation/deactivation.
*/
class PRAutoBlogger_Runware_Model_Catalog {
private const RUNWARE_API_URL = 'https://api.runware.ai/v1';
private const CACHE_TTL_SECONDS = 86400;
private const HTTP_TIMEOUT_SECONDS = 30;
/**
* Fetch the Runware model catalog, normalize to the Image_Model_Registry
* shape, and write to WP options cache. Returns true on success, false on
* API failure or parse error.
*
* Side effects: makes HTTP request to Runware API, writes WP options.
* Error handling: logs via PRAutoBlogger_Logger, never throws, always
* returns bool to allow fallback to cached list.
*
* @return bool True on successful fetch and cache update; false on API or parse error.
*/
public function sync(): bool {
try {
$api_key = $this->get_api_key();
if ( '' === $api_key ) {
PRAutoBlogger_Logger::instance()->warning(
'Runware model catalog sync: API key not configured. Using cached/fallback models.',
'runware-catalog'
);
return false;
}
$raw_models = $this->fetch_models_from_api( $api_key );
if ( empty( $raw_models ) ) {
PRAutoBlogger_Logger::instance()->warning(
'Runware model catalog fetch returned empty list. Using cached/fallback models.',
'runware-catalog'
);
return false;
}
$normalized = $this->normalize_models( $raw_models );
$with_pricing = $this->merge_pricing( $normalized );
if ( empty( $with_pricing ) ) {
PRAutoBlogger_Logger::instance()->warning(
'Runware model catalog normalization resulted in empty list. Using cached/fallback models.',
'runware-catalog'
);
return false;
}
update_option( 'prautoblogger_runware_model_cache', $with_pricing, false );
update_option( 'prautoblogger_runware_model_cache_updated_at', time(), false );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Runware model catalog synced: %d models cached.', count( $with_pricing ) ),
'runware-catalog'
);
return true;
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Runware model catalog sync failed: %s (%s)', $e->getMessage(), get_class( $e ) ),
'runware-catalog'
);
return false;
}
}
/**
* Get the normalized model list, with smart caching logic:
* - If cache is fresh (< 24h), return it.
* - If cache is stale or absent, trigger a sync.
* - If sync fails, return the last-known-good cache.
* - If no cache exists, return the hardcoded fallback (never empty).
*
* @return array<int, array<string, mixed>> Normalized model list.
*/
public function get_models(): array {
if ( ! $this->is_stale() ) {
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
if ( is_array( $cached ) && ! empty( $cached ) ) {
return $cached;
}
}
if ( $this->sync() ) {
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
if ( is_array( $cached ) && ! empty( $cached ) ) {
return $cached;
}
}
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
if ( is_array( $cached ) && ! empty( $cached ) ) {
PRAutoBlogger_Logger::instance()->info(
'Using stale Runware model cache (live sync failed).',
'runware-catalog'
);
return $cached;
}
$fallback = PRAutoBlogger_Image_Model_Registry::get_runware_fallback_models();
PRAutoBlogger_Logger::instance()->warning(
sprintf(
'No Runware model cache available; using hardcoded fallback (%d models).',
count( $fallback )
),
'runware-catalog'
);
return $fallback;
}
/**
* Get the Unix timestamp of the last successful sync, or null if never synced.
*
* @return int|null Unix timestamp, or null if never synced.
*/
public function get_last_synced_at(): ?int {
$ts = get_option( 'prautoblogger_runware_model_cache_updated_at', null );
if ( null === $ts ) {
return null;
}
$ts = (int) $ts;
return $ts > 0 ? $ts : null;
}
/**
* Check if the cache is stale (older than 24h) or absent.
*
* @return bool True if cache should be refreshed; false if still fresh.
*/
public function is_stale(): bool {
$updated_at = $this->get_last_synced_at();
if ( null === $updated_at ) {
return true;
}
return ( time() - $updated_at ) > self::CACHE_TTL_SECONDS;
}
/**
* Fetch the raw model list from Runware's /v1 endpoint via a models task.
*
* @param string $api_key Decrypted Runware API key.
* @return array Raw model list (may be empty).
* @throws \RuntimeException On HTTP error or malformed response.
*/
private function fetch_models_from_api( string $api_key ): array {
$body = wp_json_encode(
array(
array(
'taskType' => 'models',
'apiKey' => $api_key,
),
)
);
$response = wp_remote_post(
self::RUNWARE_API_URL,
array(
'timeout' => self::HTTP_TIMEOUT_SECONDS,
'headers' => array( 'Content-Type' => 'application/json' ),
'body' => $body,
)
);
if ( is_wp_error( $response ) ) {
throw new \RuntimeException(
sprintf( 'Runware models endpoint unreachable: %s', $response->get_error_message() )
);
}
$status = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $status ) {
$raw = wp_remote_retrieve_body( $response );
throw new \RuntimeException(
sprintf( 'Runware models endpoint returned HTTP %d', $status )
);
}
$raw = (string) wp_remote_retrieve_body( $response );
$decoded = json_decode( $raw, true );
if ( ! is_array( $decoded ) ) {
throw new \RuntimeException( 'Runware models response is not valid JSON.' );
}
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
throw new \RuntimeException( 'Runware API returned an error.' );
}
$data = $decoded['data'] ?? array();
return is_array( $data ) ? $data : array();
}
/**
* Normalize raw Runware model objects to the PRAutoBlogger_Image_Model_Registry
* shape: id, name, provider, cost_per_image, capabilities, description.
*
* Filters to taskType='imageInference' (text-to-image only), excluding
* inpainting (imageInference + requiresImage=true), img2img, video, upscalers.
*
* @param array<int, array<string, mixed>> $raw_models Raw API response.
* @return array<int, array<string, mixed>> Normalized models.
*/
private function normalize_models( array $raw_models ): array {
$normalized = array();
foreach ( $raw_models as $raw ) {
if ( ! is_array( $raw ) ) {
continue;
}
$task_type = $raw['taskType'] ?? '';
if ( 'imageInference' !== $task_type ) {
continue;
}
if ( isset( $raw['requiresImage'] ) && true === $raw['requiresImage'] ) {
continue;
}
$model_id = (string) ( $raw['id'] ?? '' );
if ( '' === $model_id ) {
continue;
}
$normalized[] = array(
'id' => $model_id,
'name' => (string) ( $raw['name'] ?? $model_id ),
'provider' => 'runware',
'cost_per_image' => null,
'capabilities' => array( 'image_generation' ),
'description' => (string) ( $raw['description'] ?? '' ),
);
}
return $normalized;
}
/**
* Merge pricing data from PRAutoBlogger_Runware_Image_Pricing into the
* normalized catalog. Models without pricing data get cost_per_image=null.
*
* @param array<int, array<string, mixed>> $normalized Normalized models.
* @return array<int, array<string, mixed>> Models with pricing merged.
*/
private function merge_pricing( array $normalized ): array {
$pricing_table = PRAutoBlogger_Runware_Image_Pricing::get_model_costs();
foreach ( $normalized as &$model ) {
$model_id = $model['id'] ?? '';
if ( isset( $pricing_table[ $model_id ] ) ) {
$model['cost_per_image'] = (float) $pricing_table[ $model_id ];
}
}
return $normalized;
}
/**
* Get the decrypted Runware API key from settings.
*
* @return string Plaintext key, or empty string if not configured.
*/
private function get_api_key(): string {
$stored = (string) get_option( 'prautoblogger_runware_api_key', '' );
if ( '' === $stored ) {
return '';
}
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
return '' === $decrypted ? '' : $decrypted;
}
return $stored;
}
}

View File

@@ -9,7 +9,7 @@
* 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.13.7
* Version: 0.15.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.13.7' );
define( 'PRAUTOBLOGGER_VERSION', '0.15.0' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );

View File

@@ -0,0 +1,180 @@
<?php
/**
* Tests for PRAutoBlogger_Runware_Model_Catalog.
*
* Validates live sync, fallback logic, cache staleness, and pricing merge.
*
* @package PRAutoBlogger\Tests\Providers
*/
namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
use Brain\Monkey\Actions;
class RunwareModelCatalogTest extends BaseTestCase {
protected function setUp(): void {
parent::setUp();
// Mock WP options for cache simulation.
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = array();
return $options[ $key ] ?? $default;
} );
Functions\when( 'update_option' )->alias( function ( $key, $value ) {
static $options = array();
$options[ $key ] = $value;
return true;
} );
Functions\when( 'time' )->justReturn( 1704067200 ); // 2024-01-01 00:00:00
}
/** Sync with empty or missing API key returns false. */
public function test_sync_no_api_key(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$result = $catalog->sync();
$this->assertFalse( $result );
}
/** get_models returns fallback if no cache and sync fails. */
public function test_get_models_fallback_on_sync_failure(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models();
// Should have the fallback list (15 Runware models).
$this->assertIsArray( $models );
$this->assertGreaterThan( 0, count( $models ) );
// Check at least one expected fallback model exists.
$schnell_exists = false;
foreach ( $models as $model ) {
if ( 'runware:100@1' === ( $model['id'] ?? '' ) ) {
$schnell_exists = true;
$this->assertSame( 'FLUX.1 schnell (Runware)', $model['name'] );
$this->assertSame( 'runware', $model['provider'] );
break;
}
}
$this->assertTrue( $schnell_exists, 'Fallback should include FLUX.1 schnell' );
}
/** is_stale returns true if never synced. */
public function test_is_stale_never_synced(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$this->assertTrue( $catalog->is_stale() );
}
/** get_last_synced_at returns null if never synced. */
public function test_get_last_synced_at_never(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$this->assertNull( $catalog->get_last_synced_at() );
}
/** Fallback list is never empty. */
public function test_get_models_never_empty(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
// Even with no cache and failed sync, get_models should return the fallback.
$models = $catalog->get_models();
$this->assertIsArray( $models );
$this->assertNotEmpty( $models );
// All models should have required fields.
foreach ( $models as $model ) {
$this->assertArrayHasKey( 'id', $model );
$this->assertArrayHasKey( 'name', $model );
$this->assertArrayHasKey( 'provider', $model );
$this->assertArrayHasKey( 'capabilities', $model );
}
}
/** Fallback models have correct pricing. */
public function test_fallback_models_have_pricing(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models();
$schnell = null;
$dev = null;
foreach ( $models as $model ) {
if ( 'runware:100@1' === $model['id'] ) {
$schnell = $model;
}
if ( 'runware:101@1' === $model['id'] ) {
$dev = $model;
}
}
$this->assertNotNull( $schnell );
$this->assertEqualsWithDelta( 0.0006, $schnell['cost_per_image'], 0.000001 );
$this->assertNotNull( $dev );
$this->assertEqualsWithDelta( 0.02, $dev['cost_per_image'], 0.000001 );
}
/** Fallback models all have 'image_generation' capability. */
public function test_fallback_models_have_capabilities(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models();
foreach ( $models as $model ) {
$this->assertIsArray( $model['capabilities'] );
$this->assertContains( 'image_generation', $model['capabilities'] );
}
}
/** Happy path: successful sync caches models and get_models returns them. */
public function test_sync_success_caches_and_returns_models(): void {
// Mock a valid Runware API response.
Functions\when( 'wp_remote_post' )->justReturn(
array(
'response' => array( 'code' => 200 ),
'body' => wp_json_encode(
array(
'data' => array(
array(
'id' => 'test-model-1',
'name' => 'Test Model 1',
'cost' => 0.001,
'provider' => 'runware',
),
array(
'id' => 'test-model-2',
'name' => 'Test Model 2',
'cost' => 0.002,
'provider' => 'runware',
),
),
)
),
)
);
// Mock the API key to exist.
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
static $options = array();
if ( 'prautoblogger_runware_api_key' === $key ) {
return 'test-api-key';
}
return $options[ $key ] ?? $default;
} );
Functions\when( 'update_option' )->alias( function ( $key, $value ) {
static $options = array();
$options[ $key ] = $value;
return true;
} );
// Perform the sync.
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$result = $catalog->sync();
$this->assertTrue( $result, 'Sync should return true on success' );
// Verify get_models returns the synced models (not fallback).
$models = $catalog->get_models();
$this->assertIsArray( $models );
$this->assertCount( 2, $models );
$this->assertSame( 'test-model-1', $models[0]['id'] );
$this->assertSame( 'test-model-2', $models[1]['id'] );
}
}

View File

@@ -99,4 +99,56 @@ class ImageModelRegistryTest extends BaseTestCase {
\PRAutoBlogger_Image_Model_Registry::provider_for( '' )
);
}
/**
* Test that runware:400@4 resolves to runware provider.
*/
public function test_provider_for_runware_400_at_4(): void {
$this->assertSame(
'runware',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:400@4' )
);
}
/**
* Test that runware:glm-image@0 resolves to runware provider.
*/
public function test_provider_for_runware_glm_image(): void {
$this->assertSame(
'runware',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:glm-image@0' )
);
}
/**
* Test that openai/gpt-5.4-image-2 resolves to openrouter provider.
*/
public function test_provider_for_gpt_5_4_image_2(): void {
$this->assertSame(
'openrouter',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'openai/gpt-5.4-image-2' )
);
}
/**
* Test that get_models() returns 21 models.
*/
public function test_get_models_returns_21_models(): void {
$models = \PRAutoBlogger_Image_Model_Registry::get_models();
$this->assertCount( 21, $models );
}
/**
* Test that every model has required non-empty fields.
*/
public function test_every_model_has_required_fields(): void {
$models = \PRAutoBlogger_Image_Model_Registry::get_models();
foreach ( $models as $model ) {
$this->assertNotEmpty( $model['id'] ?? '', 'Model missing id' );
$this->assertNotEmpty( $model['name'] ?? '', 'Model missing name' );
$this->assertNotEmpty( $model['provider'] ?? '', 'Model missing provider' );
$this->assertTrue( isset( $model['cost_per_image'] ), 'Model missing cost_per_image' );
$this->assertIsNumeric( $model['cost_per_image'], 'cost_per_image is not numeric' );
}
}
}