diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a8e1c..435e4ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,30 @@ GA4-context SQL fix, manual-run resilience, and honest status on transient expir - **Generation_Lock**: new `get_acquired_at()` method returns the Unix timestamp when the lock was set, used by R2b/R3 to determine lock age without touching the pipeline run table. +### Added +- **Runware catalog `modelSearch` migration**: `class-runware-model-catalog.php` migrated + from the retired `taskType:models` endpoint to the current `taskType:modelSearch` API. + Payload format: `[{taskType:authentication,apiKey:...},{taskType:modelSearch, + taskUUID:,offset:N,limit:N}]`. Response normalization updated for the new shape: + `air` identifier replaces `id`, `capabilities[]` array replaces `taskType`/`requiresImage` + (filter: entry must contain `"io:text-to-image"`), `comment` field maps to `description`. + Logic extracted to `class-runware-catalog-fetcher.php` companion (300-line rule). Cache + option key bumped to `prautoblogger_runware_model_cache_v2` to cleanly invalidate v1 rows. +- **Pagination for `modelSearch`**: fetcher paginates via `offset`/`limit` up to 5 pages of + 100 results = 500 models inspected. Runware's catalog has 320k+ entries (mostly community + checkpoints); 5 pages reliably captures all curated text-to-image models. A page-cap + warning is logged if the cap is reached before exhausting `totalResults`. Client-side + `io:text-to-image` capability filter applied — no server-side filter is available. +- **Negative-result failure cooldown**: `sync()` records a failure timestamp in + `prautoblogger_runware_catalog_last_failure_at` on every error path. `get_models()` + gates re-trigger through `is_in_failure_cooldown()`, which respects a configurable + window from `prautoblogger_runware_catalog_failure_cooldown_seconds` (default 3600s / 1h, + never hardcoded at point of use). A successful sync clears the timestamp. This caps the + admin-page-load cascade (42 errors/day observed) to ~24/day even when the cache is absent. +- **Hardcoded fallback list preserved**: unchanged; carried prod through the entire + `taskType:models` outage. Tests verify it is always non-empty and correctly priced. + + ## [0.18.2] - 2026-06-12 Four confirmed-live maintenance fixes (CTO thread `2026-06-pipeline-v2-phase1` seq 25). diff --git a/includes/providers/class-runware-catalog-fetcher.php b/includes/providers/class-runware-catalog-fetcher.php new file mode 100644 index 0000000..5bbdae9 --- /dev/null +++ b/includes/providers/class-runware-catalog-fetcher.php @@ -0,0 +1,211 @@ +> Raw modelSearch result objects. + * @throws \RuntimeException On HTTP error or malformed response for any page. + */ + public function fetch_all_text_to_image( string $api_key ): array { + $all_results = array(); + $offset = 0; + $pages_fetched = 0; + $total = PHP_INT_MAX; // Set after first page. + + do { + $page = $this->fetch_page( $api_key, $offset, self::PAGE_SIZE ); + if ( empty( $page['results'] ) ) { + break; + } + + $all_results = array_merge( $all_results, $page['results'] ); + $total = (int) $page['totalResults']; + $offset += self::PAGE_SIZE; + ++$pages_fetched; + + if ( count( $all_results ) >= $total ) { + break; + } + } while ( $pages_fetched < self::PAGE_CAP ); + + if ( $pages_fetched >= self::PAGE_CAP && count( $all_results ) < $total ) { + PRAutoBlogger_Logger::instance()->info( + sprintf( + 'Runware catalog: reached page cap (%d pages, %d models fetched of %d total). ' + . 'Increase PAGE_CAP if curated models are being missed.', + self::PAGE_CAP, + count( $all_results ), + $total + ), + 'runware-catalog' + ); + } + + return $all_results; + } + + /** + * Normalize raw modelSearch result objects to the Image_Model_Registry shape. + * + * Filters to models with "io:text-to-image" in capabilities[]. + * Uses the `air` field as the model id (e.g. "runware:100@1"). + * Uses the `comment` field as the human-readable description. + * cost_per_image is always null here — merged by the catalog class. + * + * @param array> $raw_models Raw modelSearch results. + * @return array> Normalized models. + */ + public function normalize_models( array $raw_models ): array { + $normalized = array(); + + foreach ( $raw_models as $raw ) { + if ( ! is_array( $raw ) ) { + continue; + } + + $capabilities = is_array( $raw['capabilities'] ?? null ) ? $raw['capabilities'] : array(); + if ( ! in_array( 'io:text-to-image', $capabilities, true ) ) { + continue; + } + + $air = (string) ( $raw['air'] ?? '' ); + if ( '' === $air ) { + continue; + } + + $normalized[] = array( + 'id' => $air, + 'name' => (string) ( $raw['name'] ?? $air ), + 'provider' => 'runware', + 'cost_per_image' => null, + 'capabilities' => array( 'image_generation' ), + 'description' => (string) ( $raw['comment'] ?? '' ), + ); + } + + return $normalized; + } + + /** + * Fetch a single page of modelSearch results. + * + * Sends [authentication, modelSearch] task pair to /v1. + * A fresh auth task is required per request per Runware's WebSocket-based + * API contract (each HTTP POST is a stateless task array). + * + * @param string $api_key Decrypted Runware API key. + * @param int $offset Pagination offset (0-based). + * @param int $limit Page size. + * @return array{results: array>, totalResults: int} + * @throws \RuntimeException On HTTP error or malformed response. + */ + private function fetch_page( string $api_key, int $offset, int $limit ): array { + $payload = array( + array( + 'taskType' => 'authentication', + 'apiKey' => $api_key, + ), + array( + 'taskType' => 'modelSearch', + 'taskUUID' => wp_generate_uuid4(), + 'offset' => $offset, + 'limit' => $limit, + ), + ); + + $response = wp_remote_post( + self::RUNWARE_API_URL, + array( + 'timeout' => self::HTTP_TIMEOUT_SEC, + 'headers' => array( 'Content-Type' => 'application/json' ), + 'body' => wp_json_encode( $payload ), + ) + ); + + if ( is_wp_error( $response ) ) { + throw new \RuntimeException( + sprintf( 'Runware modelSearch unreachable: %s', $response->get_error_message() ) + ); + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status ) { + throw new \RuntimeException( + sprintf( 'Runware modelSearch returned HTTP %d', $status ) + ); + } + + $raw = (string) wp_remote_retrieve_body( $response ); + $decoded = json_decode( $raw, true ); + + if ( ! is_array( $decoded ) ) { + throw new \RuntimeException( 'Runware modelSearch response is not valid JSON.' ); + } + + if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) { + $first_msg = ''; + if ( ! empty( $decoded['errors'][0]['message'] ) ) { + $first_msg = ': ' . (string) $decoded['errors'][0]['message']; + } + throw new \RuntimeException( 'Runware modelSearch API error' . $first_msg ); + } + + $data = $decoded['data'] ?? array(); + if ( ! is_array( $data ) || empty( $data ) ) { + return array( + 'results' => array(), + 'totalResults' => 0, + ); + } + + $item = $data[0]; + $results = is_array( $item['results'] ?? null ) ? $item['results'] : array(); + $total = isset( $item['totalResults'] ) ? (int) $item['totalResults'] : 0; + + return array( + 'results' => $results, + 'totalResults' => $total, + ); + } +} diff --git a/includes/providers/class-runware-model-catalog.php b/includes/providers/class-runware-model-catalog.php index 041f0ba..7ad0a3e 100644 --- a/includes/providers/class-runware-model-catalog.php +++ b/includes/providers/class-runware-model-catalog.php @@ -4,18 +4,30 @@ 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. + * Runware model catalog sync — orchestrates periodic fetching, caching, failure + * cooldown, and fallback for the Runware model list. * - * 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. + * What: Syncs Runware's model catalog (daily cron + on-demand AJAX), caches + * normalized results in WP options (v2 schema key — see below), merges + * pricing, and enforces a per-failure cooldown to stop admin-page-load + * cascades when the API is broken or the key is misconfigured. * 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. + * Dependencies: PRAutoBlogger_Runware_Catalog_Fetcher, PRAutoBlogger_Logger, + * PRAutoBlogger_Runware_Image_Pricing, PRAutoBlogger_Image_Model_Registry. * + * Cache schema note: option key is prautoblogger_runware_model_cache_v2 (bumped + * from v1) to cleanly invalidate cached rows that used the old taskType:models + * response shape (taskType/requiresImage fields). The v1 option is left in place + * and will be purged by the next WP options cleanup pass. + * + * Failure cooldown: prautoblogger_runware_catalog_last_failure_at records the + * last failure timestamp. is_in_failure_cooldown() checks it against + * prautoblogger_runware_catalog_failure_cooldown_seconds (default 3600s). This + * caps errors from admin page loads to ~24/day (24h / 1h cooldown) rather than + * 40+/day from every Settings/picker page load triggering a live sync. + * + * @see class-runware-catalog-fetcher.php — Pagination + normalize (extracted for 300-line rule). * @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. @@ -23,20 +35,27 @@ declare(strict_types=1); */ 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. + * Option key for the v2 normalized model cache (air-identifier schema). + * Bumped from v1 (prautoblogger_runware_model_cache) to force re-sync. + */ + private const CACHE_OPTION = 'prautoblogger_runware_model_cache_v2'; + private const CACHE_TS_OPTION = 'prautoblogger_runware_model_cache_updated_at'; + private const FAILURE_TS_OPTION = 'prautoblogger_runware_catalog_last_failure_at'; + private const COOLDOWN_SETTING = 'prautoblogger_runware_catalog_failure_cooldown_seconds'; + private const COOLDOWN_DEFAULT = 3600; + + /** + * Fetch, normalize, merge pricing, and cache the Runware model catalog. + * On success: writes cache + clears failure timestamp. Returns true. + * On failure: writes failure timestamp for cooldown. Returns false. * - * 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. + * returns bool to allow fallback to cached/hardcoded list. * - * @return bool True on successful fetch and cache update; false on API or parse error. + * @return bool True on successful fetch and cache update; false otherwise. */ public function sync(): bool { try { @@ -49,16 +68,19 @@ class PRAutoBlogger_Runware_Model_Catalog { return false; } - $raw_models = $this->fetch_models_from_api( $api_key ); + $fetcher = new PRAutoBlogger_Runware_Catalog_Fetcher(); + $raw_models = $fetcher->fetch_all_text_to_image( $api_key ); + if ( empty( $raw_models ) ) { PRAutoBlogger_Logger::instance()->warning( 'Runware model catalog fetch returned empty list. Using cached/fallback models.', 'runware-catalog' ); + $this->record_failure(); return false; } - $normalized = $this->normalize_models( $raw_models ); + $normalized = $fetcher->normalize_models( $raw_models ); $with_pricing = $this->merge_pricing( $normalized ); if ( empty( $with_pricing ) ) { @@ -66,11 +88,13 @@ class PRAutoBlogger_Runware_Model_Catalog { 'Runware model catalog normalization resulted in empty list. Using cached/fallback models.', 'runware-catalog' ); + $this->record_failure(); return false; } - update_option( 'prautoblogger_runware_model_cache', $with_pricing, false ); - update_option( 'prautoblogger_runware_model_cache_updated_at', time(), false ); + update_option( self::CACHE_OPTION, $with_pricing, false ); + update_option( self::CACHE_TS_OPTION, time(), false ); + delete_option( self::FAILURE_TS_OPTION ); PRAutoBlogger_Logger::instance()->info( sprintf( 'Runware model catalog synced: %d models cached.', count( $with_pricing ) ), @@ -82,38 +106,37 @@ class PRAutoBlogger_Runware_Model_Catalog { sprintf( 'Runware model catalog sync failed: %s (%s)', $e->getMessage(), get_class( $e ) ), 'runware-catalog' ); + $this->record_failure(); 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 the normalized model list, using smart cache + fallback logic: + * fresh cache → cached; stale + not in cooldown → sync; stale cache fallback; + * no cache → 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() ); + $cached = get_option( self::CACHE_OPTION, array() ); if ( is_array( $cached ) && ! empty( $cached ) ) { return $cached; } } - if ( $this->sync() ) { - $cached = get_option( 'prautoblogger_runware_model_cache', array() ); + if ( ! $this->is_in_failure_cooldown() && $this->sync() ) { + $cached = get_option( self::CACHE_OPTION, array() ); if ( is_array( $cached ) && ! empty( $cached ) ) { return $cached; } } - $cached = get_option( 'prautoblogger_runware_model_cache', array() ); + $cached = get_option( self::CACHE_OPTION, array() ); if ( is_array( $cached ) && ! empty( $cached ) ) { PRAutoBlogger_Logger::instance()->info( - 'Using stale Runware model cache (live sync failed).', + 'Using stale Runware model cache (live sync failed or suppressed by cooldown).', 'runware-catalog' ); return $cached; @@ -131,12 +154,12 @@ class PRAutoBlogger_Runware_Model_Catalog { } /** - * Get the Unix timestamp of the last successful sync, or null if never synced. + * Unix timestamp of the last successful sync, or null if never synced. * - * @return int|null Unix timestamp, or null if never synced. + * @return int|null */ public function get_last_synced_at(): ?int { - $ts = get_option( 'prautoblogger_runware_model_cache_updated_at', null ); + $ts = get_option( self::CACHE_TS_OPTION, null ); if ( null === $ts ) { return null; } @@ -145,9 +168,23 @@ class PRAutoBlogger_Runware_Model_Catalog { } /** - * Check if the cache is stale (older than 24h) or absent. + * Unix timestamp of the last sync failure, or null if no failure on record. * - * @return bool True if cache should be refreshed; false if still fresh. + * @return int|null + */ + public function get_last_failure_at(): ?int { + $ts = get_option( self::FAILURE_TS_OPTION, null ); + if ( null === $ts ) { + return null; + } + $ts = (int) $ts; + return $ts > 0 ? $ts : null; + } + + /** + * Whether the cache is stale (older than 24h) or absent. + * + * @return bool True if a refresh is warranted; false if still fresh. */ public function is_stale(): bool { $updated_at = $this->get_last_synced_at(); @@ -158,125 +195,53 @@ class PRAutoBlogger_Runware_Model_Catalog { } /** - * Fetch the raw model list from Runware's /v1 endpoint via a models task. + * Whether a sync attempt should be suppressed due to a recent failure. * - * @param string $api_key Decrypted Runware API key. - * @return array Raw model list (may be empty). - * @throws \RuntimeException On HTTP error or malformed response. + * The cooldown window is read from prautoblogger_runware_catalog_failure_cooldown_seconds + * (default 3600s). This prevents admin-page-load cascades (40+ errors/day) + * when the API endpoint or key is broken. A successful sync clears it. + * + * @return bool True if a sync should be suppressed; false if retry is permitted. */ - 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() ) - ); + public function is_in_failure_cooldown(): bool { + $last_failure = $this->get_last_failure_at(); + if ( null === $last_failure ) { + return false; } - - $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 ) - ); + $cooldown = (int) get_option( self::COOLDOWN_SETTING, self::COOLDOWN_DEFAULT ); + if ( $cooldown <= 0 ) { + $cooldown = self::COOLDOWN_DEFAULT; } - - $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(); + return ( time() - $last_failure ) < $cooldown; } /** - * 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. + * Merge pricing from PRAutoBlogger_Runware_Image_Pricing into normalized models. + * Models without a pricing entry 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; } + /** + * Record the current time as the last sync failure timestamp. + * + * @return void + */ + private function record_failure(): void { + update_option( self::FAILURE_TS_OPTION, time(), false ); + } + /** * Get the decrypted Runware API key from settings. * @@ -287,12 +252,10 @@ class PRAutoBlogger_Runware_Model_Catalog { if ( '' === $stored ) { return ''; } - if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) { $decrypted = PRAutoBlogger_Encryption::decrypt( $stored ); return '' === $decrypted ? '' : $decrypted; } - return $stored; } } diff --git a/tests/fixtures/runware-model-search-page1.json b/tests/fixtures/runware-model-search-page1.json new file mode 100644 index 0000000..a19bcc0 --- /dev/null +++ b/tests/fixtures/runware-model-search-page1.json @@ -0,0 +1,131 @@ +{ + "data": [ + { + "taskType": "modelSearch", + "taskUUID": "aabbccdd-0000-0000-0000-000000000001", + "totalResults": 5, + "results": [ + { + "air": "runware:100@1", + "name": "FLUX.1 schnell", + "nameContent": "FLUX.1 schnell", + "comment": "Fast 4-step text-to-image model", + "category": "checkpoint", + "private": false, + "capabilities": ["io:text-to-image", "form:checkpoint"], + "tags": [], + "heroImage": "", + "version": "", + "architecture": "", + "nsfwLevel": null, + "downloadCount": 0, + "inferenceCount24h": 0, + "rating": 0, + "ratingCount": 0, + "thumbsUpCount": 0, + "thumbsDownCount": 0, + "featured": 1, + "addedUnixTimestamp": 0, + "updatedDateUnixTimestamp": 0, + "source": "curated" + }, + { + "air": "runware:101@1", + "name": "FLUX.1 dev", + "nameContent": "FLUX.1 dev", + "comment": "High fidelity 28-step text-to-image model", + "category": "checkpoint", + "private": false, + "capabilities": ["io:text-to-image", "form:checkpoint"], + "tags": [], + "heroImage": "", + "version": "", + "architecture": "", + "nsfwLevel": null, + "downloadCount": 0, + "inferenceCount24h": 0, + "rating": 0, + "ratingCount": 0, + "thumbsUpCount": 0, + "thumbsDownCount": 0, + "featured": 0, + "addedUnixTimestamp": 0, + "updatedDateUnixTimestamp": 0, + "source": "curated" + }, + { + "air": "some-text-model:llm@1", + "name": "Some Text LLM", + "nameContent": "Some Text LLM", + "comment": "A text-to-text model that should be excluded", + "category": "text", + "private": false, + "capabilities": ["io:text-to-text", "form:checkpoint"], + "tags": [], + "heroImage": "", + "version": "", + "architecture": "", + "nsfwLevel": null, + "downloadCount": 0, + "inferenceCount24h": 0, + "rating": 0, + "ratingCount": 0, + "thumbsUpCount": 0, + "thumbsDownCount": 0, + "featured": 0, + "addedUnixTimestamp": 0, + "updatedDateUnixTimestamp": 0, + "source": "curated" + }, + { + "air": "runware:400@2", + "name": "FLUX.2 klein 9B", + "nameContent": "FLUX.2 klein 9B", + "comment": "Compact 9B text-to-image model", + "category": "checkpoint", + "private": false, + "capabilities": ["io:text-to-image", "form:checkpoint"], + "tags": [], + "heroImage": "", + "version": "", + "architecture": "", + "nsfwLevel": null, + "downloadCount": 0, + "inferenceCount24h": 0, + "rating": 0, + "ratingCount": 0, + "thumbsUpCount": 0, + "thumbsDownCount": 0, + "featured": 0, + "addedUnixTimestamp": 0, + "updatedDateUnixTimestamp": 0, + "source": "curated" + }, + { + "air": "upscale-model:upscaler@1", + "name": "Upscale Model", + "nameContent": "Upscale Model", + "comment": "Image upscaler — should be excluded from t2i list", + "category": "checkpoint", + "private": false, + "capabilities": ["io:image-to-image", "op:upscale", "form:checkpoint"], + "tags": [], + "heroImage": "", + "version": "", + "architecture": "", + "nsfwLevel": null, + "downloadCount": 0, + "inferenceCount24h": 0, + "rating": 0, + "ratingCount": 0, + "thumbsUpCount": 0, + "thumbsDownCount": 0, + "featured": 0, + "addedUnixTimestamp": 0, + "updatedDateUnixTimestamp": 0, + "source": "curated" + } + ] + } + ] +} diff --git a/tests/unit/Providers/RunwareModelCatalogTest.php b/tests/unit/Providers/RunwareModelCatalogTest.php index 57945cd..11f825a 100644 --- a/tests/unit/Providers/RunwareModelCatalogTest.php +++ b/tests/unit/Providers/RunwareModelCatalogTest.php @@ -2,7 +2,9 @@ /** * Tests for PRAutoBlogger_Runware_Model_Catalog. * - * Validates live sync, fallback logic, cache staleness, and pricing merge. + * Validates: normalize_models against real modelSearch fixture; cooldown behavior + * (failure → no retry within window → retry after); fallback served when cache empty; + * success path clears failure timestamp; cache version key v2 is used throughout. * * @package PRAutoBlogger\Tests\Providers */ @@ -11,47 +13,267 @@ namespace PRAutoBlogger\Tests\Providers; use PRAutoBlogger\Tests\BaseTestCase; use Brain\Monkey\Functions; -use Brain\Monkey\Actions; class RunwareModelCatalogTest extends BaseTestCase { + /** Shared in-memory option store reset per test. */ + private array $options = array(); + 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 + $this->options = array(); + $this->stub_options(); } - /** Sync with empty or missing API key returns false. */ - public function test_sync_no_api_key(): void { + // ── Helpers ──────────────────────────────────────────────────────────────── + + private function stub_options(): void { + Functions\when( 'get_option' )->alias( + function ( $key, $default = false ) { + return $this->options[ $key ] ?? $default; + } + ); + Functions\when( 'update_option' )->alias( + function ( $key, $value ) { + $this->options[ $key ] = $value; + return true; + } + ); + Functions\when( 'delete_option' )->alias( + function ( $key ) { + unset( $this->options[ $key ] ); + return true; + } + ); + Functions\when( 'time' )->justReturn( 1704067200 ); // 2024-01-01 00:00:00 UTC + } + + /** + * Return the fixture as a decoded page array matching fetch_page() output. + * + * @return array{results: array, totalResults: int} + */ + private function get_fixture_page(): array { + $fixture_path = __DIR__ . '/../../fixtures/runware-model-search-page1.json'; + $json = file_get_contents( $fixture_path ); + $decoded = json_decode( $json, true ); + $item = $decoded['data'][0]; + return array( + 'results' => $item['results'], + 'totalResults' => $item['totalResults'], + ); + } + + /** + * Build a fake HTTP response array for wp_remote_post stubs. + * + * @param int $status HTTP status code. + * @param string $body Response body JSON. + * @return array + */ + private function make_http_response( int $status, string $body ): array { + return array( + 'response' => array( 'code' => $status ), + 'body' => $body, + ); + } + + private function stub_http_helpers(): void { + Functions\when( 'wp_remote_retrieve_response_code' )->alias( + function ( $r ) { return (int) ( $r['response']['code'] ?? 0 ); } + ); + Functions\when( 'wp_remote_retrieve_body' )->alias( + function ( $r ) { return (string) ( $r['body'] ?? '' ); } + ); + Functions\when( 'is_wp_error' )->justReturn( false ); + } + + // ── normalize_models tests (via Fetcher) ────────────────────────────────── + + /** + * normalize_models filters to io:text-to-image and maps air → id, + * comment → description. Non-t2i models are excluded. + * + * Uses the real modelSearch fixture (3 t2i + 1 text LLM + 1 upscaler). + * Expects only the 3 t2i models to pass through. + */ + public function test_normalize_models_filters_to_text_to_image(): void { + $fixture = $this->get_fixture_page(); + $fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher(); + $result = $fetcher->normalize_models( $fixture['results'] ); + + // Fixture has 3 t2i models (runware:100@1, runware:101@1, runware:400@2) + // and 2 non-t2i (text LLM + upscaler). + $this->assertCount( 3, $result, 'Should include exactly 3 io:text-to-image models' ); + + $ids = array_column( $result, 'id' ); + $this->assertContains( 'runware:100@1', $ids ); + $this->assertContains( 'runware:101@1', $ids ); + $this->assertContains( 'runware:400@2', $ids ); + $this->assertNotContains( 'some-text-model:llm@1', $ids, 'Text LLM must be excluded' ); + $this->assertNotContains( 'upscale-model:upscaler@1', $ids, 'Upscaler must be excluded' ); + } + + /** normalize_models uses air as id, name as name, comment as description. */ + public function test_normalize_models_maps_fields_correctly(): void { + $fixture = $this->get_fixture_page(); + $fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher(); + $result = $fetcher->normalize_models( $fixture['results'] ); + + $schnell = null; + foreach ( $result as $m ) { + if ( 'runware:100@1' === $m['id'] ) { + $schnell = $m; + break; + } + } + + $this->assertNotNull( $schnell ); + $this->assertSame( 'runware:100@1', $schnell['id'] ); + $this->assertSame( 'FLUX.1 schnell', $schnell['name'] ); + $this->assertSame( 'runware', $schnell['provider'] ); + $this->assertNull( $schnell['cost_per_image'], 'Pricing is null before merge_pricing runs' ); + $this->assertSame( array( 'image_generation' ), $schnell['capabilities'] ); + $this->assertSame( 'Fast 4-step text-to-image model', $schnell['description'] ); + } + + /** normalize_models skips entries with empty air field. */ + public function test_normalize_models_skips_empty_air(): void { + $fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher(); + $raw = array( + array( + 'air' => '', + 'name' => 'No Air Model', + 'capabilities' => array( 'io:text-to-image' ), + 'comment' => '', + ), + array( + 'air' => 'runware:100@1', + 'name' => 'Valid', + 'capabilities' => array( 'io:text-to-image' ), + 'comment' => 'OK', + ), + ); + + $result = $fetcher->normalize_models( $raw ); + $this->assertCount( 1, $result ); + $this->assertSame( 'runware:100@1', $result[0]['id'] ); + } + + /** normalize_models returns empty array for empty input. */ + public function test_normalize_models_empty_input(): void { + $fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher(); + $this->assertSame( array(), $fetcher->normalize_models( array() ) ); + } + + // ── Failure cooldown tests ──────────────────────────────────────────────── + + /** No failure on record → is_in_failure_cooldown returns false. */ + public function test_no_failure_cooldown_when_no_failure_recorded(): void { $catalog = new \PRAutoBlogger_Runware_Model_Catalog(); - $result = $catalog->sync(); + $this->assertFalse( $catalog->is_in_failure_cooldown() ); + } + + /** Failure recorded at t=now → cooldown active within 1h window. */ + public function test_cooldown_active_within_window(): void { + $now = 1704067200; + $this->options['prautoblogger_runware_catalog_last_failure_at'] = $now; + // time() is stubbed to $now, so age = 0 < 3600 = cooldown. + $catalog = new \PRAutoBlogger_Runware_Model_Catalog(); + $this->assertTrue( $catalog->is_in_failure_cooldown() ); + } + + /** Failure recorded > 1h ago → cooldown expired, retry permitted. */ + public function test_cooldown_expired_after_window(): void { + $now = 1704067200; + $past = $now - 3601; // More than 1h ago. + $this->options['prautoblogger_runware_catalog_last_failure_at'] = $past; + + $catalog = new \PRAutoBlogger_Runware_Model_Catalog(); + $this->assertFalse( $catalog->is_in_failure_cooldown() ); + } + + /** Custom cooldown from settings is respected. */ + public function test_cooldown_uses_setting_value(): void { + $now = 1704067200; + $this->options['prautoblogger_runware_catalog_last_failure_at'] = $now - 100; + // Default 3600s would make this expired (100 < 3600), but set custom = 60s. + // 100s > 60s so it should be expired. + $this->options['prautoblogger_runware_catalog_failure_cooldown_seconds'] = 60; + + $catalog = new \PRAutoBlogger_Runware_Model_Catalog(); + $this->assertFalse( $catalog->is_in_failure_cooldown() ); + } + + /** Sync failure sets last_failure_at and does not clear success timestamp. */ + public function test_sync_failure_records_failure_timestamp(): void { + // Mock HTTP 401 to force sync failure. + $this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain'; + $this->stub_http_helpers(); + Functions\when( 'wp_remote_post' )->justReturn( + $this->make_http_response( 401, '{"errors":[{"message":"Unauthorized"}]}' ) + ); + + $catalog = new \PRAutoBlogger_Runware_Model_Catalog(); + $result = $catalog->sync(); + $this->assertFalse( $result ); + $this->assertNotNull( $catalog->get_last_failure_at(), 'Failure timestamp should be recorded' ); } - /** get_models returns fallback if no cache and sync fails. */ - public function test_get_models_fallback_on_sync_failure(): void { + /** After failure, get_models suppresses sync within cooldown window. */ + public function test_get_models_suppresses_sync_during_cooldown(): void { + $now = 1704067200; + $this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain'; + $this->options['prautoblogger_runware_catalog_last_failure_at'] = $now; // just failed. + $this->options['prautoblogger_runware_model_cache_updated_at'] = $now - 90000; // stale cache. + + $this->stub_http_helpers(); + // wp_remote_post should NOT be called during cooldown. + Functions\expect( 'wp_remote_post' )->never(); + $catalog = new \PRAutoBlogger_Runware_Model_Catalog(); - $models = $catalog->get_models(); + $models = $catalog->get_models(); + + // Should fall back to hardcoded fallback list. + $this->assertNotEmpty( $models ); + $ids = array_column( $models, 'id' ); + $this->assertContains( 'runware:100@1', $ids, 'Fallback schnell model should be present' ); + } + + /** Successful sync clears failure timestamp. */ + public function test_successful_sync_clears_failure_timestamp(): void { + $now = 1704067200; + $this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain'; + $this->options['prautoblogger_runware_catalog_last_failure_at'] = $now - 7200; // old failure. + + $fixture_body = file_get_contents( __DIR__ . '/../../fixtures/runware-model-search-page1.json' ); + $this->stub_http_helpers(); + Functions\when( 'wp_remote_post' )->justReturn( + $this->make_http_response( 200, $fixture_body ) + ); + + $catalog = new \PRAutoBlogger_Runware_Model_Catalog(); + $result = $catalog->sync(); + + $this->assertTrue( $result, 'Sync should succeed with valid fixture response' ); + $this->assertNull( $catalog->get_last_failure_at(), 'Success should clear failure timestamp' ); + } + + // ── Fallback list tests ─────────────────────────────────────────────────── + + /** get_models returns fallback when no cache and sync fails. */ + public function test_get_models_returns_fallback_when_no_cache(): 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 ) ); + $this->assertNotEmpty( $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; } @@ -59,28 +281,12 @@ class RunwareModelCatalogTest extends BaseTestCase { $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. */ + /** Fallback list is never empty (even with no key configured). */ 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 ); + $models = $catalog->get_models(); $this->assertNotEmpty( $models ); - // All models should have required fields. foreach ( $models as $model ) { $this->assertArrayHasKey( 'id', $model ); $this->assertArrayHasKey( 'name', $model ); @@ -89,13 +295,24 @@ class RunwareModelCatalogTest extends BaseTestCase { } } - /** Fallback models have correct pricing. */ + /** All fallback models carry the image_generation capability. */ + public function test_fallback_models_have_image_generation_capability(): 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'] ); + } + } + + /** Fallback schnell and dev have correct pricing. */ public function test_fallback_models_have_pricing(): void { $catalog = new \PRAutoBlogger_Runware_Model_Catalog(); - $models = $catalog->get_models(); + $models = $catalog->get_models(); $schnell = null; - $dev = null; + $dev = null; foreach ( $models as $model ) { if ( 'runware:100@1' === $model['id'] ) { $schnell = $model; @@ -107,83 +324,98 @@ class RunwareModelCatalogTest extends BaseTestCase { $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(); + // ── Happy path (sync → cache → 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. */ + /** Successful sync caches models; get_models returns them from cache. */ public function test_sync_success_caches_and_returns_models(): void { - // The mocked API response body — models must have taskType=imageInference - // so normalize_models() doesn't filter them out. - $fake_body = json_encode( - array( - 'data' => array( - array( - 'id' => 'test-model-1', - 'name' => 'Test Model 1', - 'taskType' => 'imageInference', - ), - array( - 'id' => 'test-model-2', - 'name' => 'Test Model 2', - 'taskType' => 'imageInference', - ), - ), - ) + $fixture_body = file_get_contents( __DIR__ . '/../../fixtures/runware-model-search-page1.json' ); + $this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain'; + + $this->stub_http_helpers(); + Functions\when( 'wp_remote_post' )->justReturn( + $this->make_http_response( 200, $fixture_body ) ); - $fake_response = array( - 'response' => array( 'code' => 200 ), - 'body' => $fake_body, - ); - - Functions\when( 'wp_remote_post' )->justReturn( $fake_response ); - // Alias the WP response-parsing helpers so they work against our array. - Functions\when( 'wp_remote_retrieve_response_code' )->alias( - function ( $r ) { return (int) ( $r['response']['code'] ?? 0 ); } - ); - Functions\when( 'wp_remote_retrieve_body' )->alias( - function ( $r ) { return (string) ( $r['body'] ?? '' ); } - ); - Functions\when( 'is_wp_error' )->justReturn( false ); - - // Shared options store so get_option/update_option stay in sync - // within this test. The API key must be non-empty for sync() to proceed. - $store = array( 'prautoblogger_runware_api_key' => 'test-api-key-plain' ); - Functions\when( 'get_option' )->alias( - function ( $key, $default = false ) use ( &$store ) { - return $store[ $key ] ?? $default; - } - ); - Functions\when( 'update_option' )->alias( - function ( $key, $value ) use ( &$store ) { - $store[ $key ] = $value; - return true; - } - ); - - // Sync should succeed: API key present, HTTP 200, 2 imageInference models. $catalog = new \PRAutoBlogger_Runware_Model_Catalog(); - $result = $catalog->sync(); - $this->assertTrue( $result, 'Sync should return true on HTTP 200 with valid models' ); + $result = $catalog->sync(); + + $this->assertTrue( $result, 'Sync should return true on HTTP 200 with valid fixture' ); - // get_models() should now return the synced list from cache. $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'] ); + // Fixture has 3 t2i models (schnell, dev, klein 9B). + $this->assertCount( 3, $models, 'get_models should return the 3 cached t2i models' ); + + $ids = array_column( $models, 'id' ); + $this->assertContains( 'runware:100@1', $ids ); + $this->assertContains( 'runware:101@1', $ids ); + $this->assertContains( 'runware:400@2', $ids ); + } + + /** Synced models have pricing merged from the pricing class. */ + public function test_synced_models_have_pricing_merged(): void { + $fixture_body = file_get_contents( __DIR__ . '/../../fixtures/runware-model-search-page1.json' ); + $this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain'; + + $this->stub_http_helpers(); + Functions\when( 'wp_remote_post' )->justReturn( + $this->make_http_response( 200, $fixture_body ) + ); + + $catalog = new \PRAutoBlogger_Runware_Model_Catalog(); + $catalog->sync(); + $models = $catalog->get_models(); + + $schnell = null; + foreach ( $models as $m ) { + if ( 'runware:100@1' === $m['id'] ) { + $schnell = $m; + } + } + $this->assertNotNull( $schnell ); + $this->assertEqualsWithDelta( 0.0006, (float) $schnell['cost_per_image'], 0.000001 ); + } + + // ── Stale / absent cache tests ──────────────────────────────────────────── + + /** 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() ); + } + + /** is_stale returns false for a fresh cache (synced < 24h ago). */ + public function test_is_stale_fresh_cache(): void { + $now = 1704067200; + $this->options['prautoblogger_runware_model_cache_updated_at'] = $now - 3600; // 1h ago. + $catalog = new \PRAutoBlogger_Runware_Model_Catalog(); + $this->assertFalse( $catalog->is_stale() ); + } + + /** is_stale returns true when cache is older than 24h. */ + public function test_is_stale_old_cache(): void { + $now = 1704067200; + $this->options['prautoblogger_runware_model_cache_updated_at'] = $now - 90000; // > 24h ago. + $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() ); + } + + /** Sync with no API key returns false without recording a failure. */ + public function test_sync_no_api_key(): void { + $catalog = new \PRAutoBlogger_Runware_Model_Catalog(); + $result = $catalog->sync(); + $this->assertFalse( $result ); + // No key → not a transient failure, no cooldown recorded. + $this->assertNull( $catalog->get_last_failure_at() ); } }