From def070d15a7894001467f6796e6130cdae633630 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Sun, 26 Apr 2026 04:27:17 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Live=20Runware=20model=20catalog=20sync?= =?UTF-8?q?=20=E2=80=94=20v0.15.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 38 +++ includes/admin/class-image-model-registry.php | 87 +++-- .../admin/class-settings-fields-extended.php | 8 + includes/class-activator.php | 5 + includes/class-ajax-handlers.php | 48 +++ includes/class-deactivator.php | 1 + includes/class-prautoblogger.php | 3 + .../providers/class-runware-image-pricing.php | 1 + .../providers/class-runware-model-catalog.php | 298 ++++++++++++++++++ prautoblogger.php | 4 +- .../Providers/RunwareModelCatalogTest.php | 125 ++++++++ 12 files changed, 589 insertions(+), 31 deletions(-) mode change 100755 => 100644 includes/class-deactivator.php create mode 100644 includes/providers/class-runware-model-catalog.php create mode 100644 tests/unit/Providers/RunwareModelCatalogTest.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b5e417f..54b5042 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` | --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b20b03..316e7bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,44 @@ 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 diff --git a/includes/admin/class-image-model-registry.php b/includes/admin/class-image-model-registry.php index ae8e70b..5f231f1 100644 --- a/includes/admin/class-image-model-registry.php +++ b/includes/admin/class-image-model-registry.php @@ -6,22 +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. - * As of 2026-04-26, registry includes 21 models across Runware (15) and - * OpenRouter (6), ordered cheapest-to-most-expensive within each provider. + * 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. @@ -34,15 +37,44 @@ class PRAutoBlogger_Image_Model_Registry { * Ordered cheapest-to-most-expensive within each provider, with the * recommended default (Runware schnell) at the top. * - * Last verified: 2026-04-26. To update: check https://runware.ai/models (text-to-image section) - * and https://openrouter.ai/api/v1/models filtering output_modalities containing 'image'. - * Exclude: inpainting (needs mask), img2img (needs seedImage), video, upscalers, background removal. - * * @return array> */ public static function get_models(): array { + $catalog = new PRAutoBlogger_Runware_Model_Catalog(); + $runware_models = $catalog->get_models(); + $openrouter_models = self::get_openrouter_models(); + return array_merge( $runware_models, $openrouter_models ); + } + + /** + * Return the provider id for a known model, or empty string if the + * model id is not in the registry. + * + * @param string $model_id Model slug from the admin UI. + * @return string Provider id ('runware' | 'openrouter' | ''). + */ + public static function provider_for( string $model_id ): string { + foreach ( self::get_models() as $model ) { + if ( ( $model['id'] ?? '' ) === $model_id ) { + return (string) ( $model['provider'] ?? '' ); + } + } + 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> + */ + public static function get_runware_fallback_models(): array { return array( - // RUNWARE MODELS array( 'id' => 'runware:100@1', 'name' => 'FLUX.1 schnell (Runware)', @@ -163,7 +195,22 @@ class PRAutoBlogger_Image_Model_Registry { 'capabilities' => array( 'image_generation' ), 'description' => __( 'GLM-Image. Hybrid autoregressive+diffusion, excellent text rendering.', 'prautoblogger' ), ), - // OPENROUTER MODELS + ); + } + + /** + * 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> + */ + private static function get_openrouter_models(): array { + return array( array( 'id' => 'google/gemini-2.5-flash-image', 'name' => 'Gemini 2.5 Flash Image (OpenRouter)', @@ -214,20 +261,4 @@ class PRAutoBlogger_Image_Model_Registry { ), ); } - - /** - * Return the provider id for a known model, or empty string if the - * model id is not in the registry. - * - * @param string $model_id Model slug from the admin UI. - * @return string Provider id ('runware' | 'openrouter' | ''). - */ - public static function provider_for( string $model_id ): string { - foreach ( self::get_models() as $model ) { - if ( ( $model['id'] ?? '' ) === $model_id ) { - return (string) ( $model['provider'] ?? '' ); - } - } - return ''; - } } diff --git a/includes/admin/class-settings-fields-extended.php b/includes/admin/class-settings-fields-extended.php index c8c6466..17d4bd8 100644 --- a/includes/admin/class-settings-fields-extended.php +++ b/includes/admin/class-settings-fields-extended.php @@ -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' ), + ), + ); } } diff --git a/includes/class-activator.php b/includes/class-activator.php index 59ad098..958de6c 100755 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -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' ); + } } /** diff --git a/includes/class-ajax-handlers.php b/includes/class-ajax-handlers.php index b56fb53..64740b9 100644 --- a/includes/class-ajax-handlers.php +++ b/includes/class-ajax-handlers.php @@ -157,6 +157,54 @@ class PRAutoBlogger_Ajax_Handlers { * @return PRAutoBlogger_OpenRouter_Model_Registry */ public function get_registry(): PRAutoBlogger_OpenRouter_Model_Registry { + + /** + * 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() ) ); + } + } + return $this->model_registry; } } diff --git a/includes/class-deactivator.php b/includes/class-deactivator.php old mode 100755 new mode 100644 index 851d894..680d280 --- a/includes/class-deactivator.php +++ b/includes/class-deactivator.php @@ -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 ) { diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index 90e5b96..efa399b 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -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' ) ); +t 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' ) ); diff --git a/includes/providers/class-runware-image-pricing.php b/includes/providers/class-runware-image-pricing.php index 3bc4df7..870d671 100644 --- a/includes/providers/class-runware-image-pricing.php +++ b/includes/providers/class-runware-image-pricing.php @@ -15,6 +15,7 @@ declare(strict_types=1); * 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. */ diff --git a/includes/providers/class-runware-model-catalog.php b/includes/providers/class-runware-model-catalog.php new file mode 100644 index 0000000..041f0ba --- /dev/null +++ b/includes/providers/class-runware-model-catalog.php @@ -0,0 +1,298 @@ +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> 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> $raw_models Raw API response. + * @return array> 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> $normalized Normalized models. + * @return array> 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; + } +} diff --git a/prautoblogger.php b/prautoblogger.php index f4226a7..7bb644d 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -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.14.0 + * 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.14.0' ); +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__ ) ); diff --git a/tests/unit/Providers/RunwareModelCatalogTest.php b/tests/unit/Providers/RunwareModelCatalogTest.php new file mode 100644 index 0000000..d985ed1 --- /dev/null +++ b/tests/unit/Providers/RunwareModelCatalogTest.php @@ -0,0 +1,125 @@ +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'] ); + } + } +}