From 350c4626c417c6e3203b5ecc1ca6827102f01474 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Thu, 16 Apr 2026 09:26:13 +0700 Subject: [PATCH] feat(models): OpenRouter model registry + interface + cron wiring (model-picker commit 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the smart model picker. Fetches, normalizes, and caches the OpenRouter model list (/api/v1/models — free, unauthenticated). Daily refresh via the existing prautoblogger_daily_generation cron with 12h idempotency. 3-retry exponential backoff on transient failures; serves last-good option payload when stale-and-fetch-fails. New files: - interface-model-registry.php — Phase 3-aware contract (94 lines) - class-openrouter-model-registry.php — fetch + cache + query (226 lines) - class-openrouter-model-normalizer.php — raw → standardized (114 lines) Zero-coupling: no PRAUTOBLOGGER_* constants inside the service classes. Config injected via constructor. Phase 2 lift into a shared Composer package requires only namespace rename, zero internal edits. Cost impact: $0/month (free endpoint, no LLM tokens). Agent-Session: cowork-2026-04-16-model-picker-commit1 --- ARCHITECTURE.md | 12 + CHANGELOG.md | 12 + includes/class-autoloader.php | 1 + includes/class-prautoblogger.php | 33 ++- .../class-openrouter-model-normalizer.php | 114 ++++++++ .../class-openrouter-model-registry.php | 226 ++++++++++++++++ .../services/interface-model-registry.php | 94 +++++++ .../Services/OpenRouterModelRegistryTest.php | 252 ++++++++++++++++++ 8 files changed, 741 insertions(+), 3 deletions(-) create mode 100644 includes/services/class-openrouter-model-normalizer.php create mode 100644 includes/services/class-openrouter-model-registry.php create mode 100644 includes/services/interface-model-registry.php create mode 100644 tests/unit/Services/OpenRouterModelRegistryTest.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 619861b..71ee60c 100755 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -139,6 +139,12 @@ prautoblogger/ │ │ ├── class-cloudflare-image-support.php # Token, account, URL, and log helpers for the CF provider │ │ └── (new providers go here — see CONVENTIONS.md) │ │ +│ ├── services/ +│ │ ├── interface-model-registry.php # Contract for provider-specific model registries +│ │ ├── class-openrouter-model-registry.php # Fetches, caches, queries the OpenRouter model list +│ │ ├── class-openrouter-model-normalizer.php # Maps raw OpenRouter data → standardized record shape +│ │ └── (Phase 3: class-cloudflare-workers-ai-model-registry.php) +│ │ │ ├── frontend/ │ │ └── class-posts-widget.php # [prautoblogger_posts] shortcode + REST endpoint │ │ @@ -347,6 +353,8 @@ All prefixed with `prautoblogger_`: | `prautoblogger_cloudflare_account_id` | Cloudflare account UUID (plaintext — identifier, not secret) | | `prautoblogger_image_model` | Image model alias: `flux-1-schnell` (default) or `flux-1-dev` | | `prautoblogger_image_style_suffix` | Text appended to every image prompt (default: CEO-locked 90s infomercial prompt) | +| `prautoblogger_openrouter_model_registry` | Normalized OpenRouter model list (JSON array, daily refresh, serves as durable cache) | +| `prautoblogger_openrouter_model_registry_fetched_at` | Unix timestamp of last successful model registry refresh | ### Post Meta @@ -425,6 +433,10 @@ Each pipeline execution generates a UUID (`run_id`) that tags every `prab_genera ### #14: Reddit RSS replaces PullPush.io (and earlier Reddit OAuth) Reddit rejected our OAuth API application (April 2026). We initially switched to PullPush.io, but its index was frequently stale or unavailable. Reddit's RSS/Atom feeds (`/r/{sub}/hot.rss`) proved the most reliable option — they work from datacenter IPs where .json gets 403, require no auth, and have no apparent rate limit. The .json endpoints are kept as a fallback for posts and as the sole source for comment data. Each collected item's metadata includes a `data_source` field (`reddit_rss` or `reddit_json`) for auditability. +### #18: OpenRouter model registry — daily refresh, WP option + transient cache, zero-coupling + +The admin model picker (v1) needs to list OpenRouter models with pricing and capabilities. We fetch `https://openrouter.ai/api/v1/models` (free, unauthenticated) once daily and store the normalized payload in a WP option fronted by a 24h transient. On stale-and-fetch-fails, we serve last-good + surface a warning. The registry class (`class-openrouter-model-registry.php`) takes all config via constructor (option name, transient name, endpoint URL) — no PRAUTOBLOGGER_* constants inside the class body. Phase 2 lifts it into a shared Composer package with zero internal edits. Phase 3 adds a parallel `Cloudflare_WorkersAI_Model_Registry` behind the same interface. Capability vocabulary: `text→text`, `text+image→text`, `text+audio→text`, `text→image`, `text→audio`, `text→video`, `text→embedding`. Cost: $0/month. + ### #16: Image generation via Cloudflare Workers AI (FLUX.1), direct (not via AI Gateway) The image-generation layer (started 2026-04-15, tracked in `convo/prautoblogger/threads/2026-04-image-pipeline/`) uses FLUX.1 [schnell] on Cloudflare Workers AI as its default model, chosen for its ~$0.0011/MP cost and 2–3 sec latency. FLUX.1 [dev] is a ~4× cost upgrade exposed as a dropdown for specific posts that warrant it. Calls go directly to `https://api.cloudflare.com/client/v4/accounts/{id}/ai/run/@cf/black-forest-labs/...`, *not* through the Cloudflare AI Gateway that we use for OpenRouter — the gateway route to Workers AI currently 403s (pre-existing open issue). The provider lives behind `PRAutoBlogger_Image_Provider_Interface`, so the decision is trivially reversible: switching to a different image API (DALL-E, Replicate, a future AI Gateway route) is a new class implementation plus a one-line swap in the pipeline wiring. Trade-off: we run two different Cloudflare integration paths (gateway for OpenRouter LLMs, direct for Workers AI images) until the gateway route stabilizes — minor cognitive overhead, zero functional downside. diff --git a/CHANGELOG.md b/CHANGELOG.md index 70aa1fd..b4ef24c 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project uses [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- **OpenRouter model registry (model-picker commit 1).** Foundation for the + smart model picker: fetches, normalizes, and caches the OpenRouter model + list (`/api/v1/models` — free, unauthenticated). Daily refresh via the + existing `prautoblogger_daily_generation` cron hook with 12h idempotency. + - `includes/services/interface-model-registry.php` — Phase 3-aware contract. + - `includes/services/class-openrouter-model-registry.php` — fetch + cache + query. + - `includes/services/class-openrouter-model-normalizer.php` — raw → standardized shape. + - Capability vocabulary: `text→text`, `text+image→text`, `text→embedding`, etc. + - Zero-coupling: no PRAUTOBLOGGER_* constants inside the class — Phase 2 lift + into a shared Composer package requires only a namespace rename. + - Cost impact: $0/month (free endpoint, no LLM tokens). + - **Image provider: Cloudflare Workers AI (FLUX.1 family).** First commit of the image + Instagram A/B pipeline workstream. Ships the provider, its pricing + validator helpers, four new settings fields in a new "Images" diff --git a/includes/class-autoloader.php b/includes/class-autoloader.php index 114f444..8543373 100755 --- a/includes/class-autoloader.php +++ b/includes/class-autoloader.php @@ -26,6 +26,7 @@ class PRAutoBlogger_Autoloader { 'providers/', 'models/', 'frontend/', + 'services/', ]; /** diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index c7d5acc..c133596 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -18,11 +18,11 @@ declare(strict_types=1); */ class PRAutoBlogger { - /** - * Whether the plugin has been initialized. - */ private bool $initialized = false; + /** @var PRAutoBlogger_OpenRouter_Model_Registry|null Lazy-loaded singleton. */ + private ?PRAutoBlogger_OpenRouter_Model_Registry $model_registry = null; + /** * Register all hooks and initialize the plugin. * @@ -118,6 +118,11 @@ class PRAutoBlogger { private function register_cron_hooks(): void { add_action( 'prautoblogger_daily_generation', [ $this, 'on_daily_generation' ] ); add_action( 'prautoblogger_collect_metrics', [ $this, 'on_collect_metrics' ] ); + + // Model registry refresh — fires inside on_daily_generation() before the + // generation lock, so a stuck lock doesn't freeze the registry too. + $registry = $this->get_model_registry(); + add_action( 'prautoblogger_refresh_model_registry', [ $registry, 'refresh' ] ); } /** Register AJAX handlers for admin actions. */ @@ -218,6 +223,10 @@ class PRAutoBlogger { * @return void */ public function on_daily_generation(): void { + // Refresh the OpenRouter model registry (idempotent, skips if <12h old). + // Fires BEFORE the generation lock so a stuck lock doesn't block the refresh. + do_action( 'prautoblogger_refresh_model_registry', false ); + if ( ! $this->acquire_generation_lock() ) { PRAutoBlogger_Logger::instance()->info( 'Daily generation skipped: already running (lock held).', 'scheduler' ); return; @@ -361,6 +370,24 @@ class PRAutoBlogger { return $result > 0; } + /** + * Lazy-load the OpenRouter model registry singleton. + * + * Config (option names, transient names) is injected here — the registry + * class itself has no knowledge of PRAUTOBLOGGER_* constants. + * + * @return PRAutoBlogger_OpenRouter_Model_Registry + */ + public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry { + if ( null === $this->model_registry ) { + $this->model_registry = new PRAutoBlogger_OpenRouter_Model_Registry( + 'prautoblogger_openrouter_model_registry', + 'prautoblogger_openrouter_model_registry_cache' + ); + } + return $this->model_registry; + } + /** Release the generation mutex. */ private function release_generation_lock(): void { global $wpdb; diff --git a/includes/services/class-openrouter-model-normalizer.php b/includes/services/class-openrouter-model-normalizer.php new file mode 100644 index 0000000..db5bf9b --- /dev/null +++ b/includes/services/class-openrouter-model-normalizer.php @@ -0,0 +1,114 @@ + + */ + private const CAPABILITY_MAP = [ + 'text→text' => [ 'in' => [ 'text' ], 'out' => [ 'text' ] ], + 'text+image→text' => [ 'in' => [ 'text', 'image' ], 'out' => [ 'text' ] ], + 'text+audio→text' => [ 'in' => [ 'text', 'audio' ], 'out' => [ 'text' ] ], + 'text→image' => [ 'in' => [ 'text' ], 'out' => [ 'image' ] ], + 'text→audio' => [ 'in' => [ 'text' ], 'out' => [ 'audio' ] ], + 'text→video' => [ 'in' => [ 'text' ], 'out' => [ 'video' ] ], + 'text→embedding' => [ 'in' => [ 'text' ], 'out' => [ 'embedding' ] ], + ]; + + /** + * Normalize raw OpenRouter model records into the standardized shape. + * + * Unknown architectures degrade to 'text→text' capability — never throws. + * Pricing is converted from per-token (OpenRouter format) to per-million. + * + * @param array $raw_models Raw 'data' array from the OpenRouter API. + * + * @return array Normalized records, sorted by name ascending. + */ + public function normalize( array $raw_models ): array { + $normalized = []; + + foreach ( $raw_models as $model ) { + if ( ! is_array( $model ) || ! isset( $model['id'] ) ) { + continue; + } + + $input_modalities = (array) ( $model['architecture']['input_modalities'] ?? [ 'text' ] ); + $output_modalities = (array) ( $model['architecture']['output_modalities'] ?? [ 'text' ] ); + + $name_parts = explode( '/', (string) $model['id'], 2 ); + + // OpenRouter prices are per-token; convert to per-million. + $prompt_price = (float) ( $model['pricing']['prompt'] ?? 0 ); + $completion_price = (float) ( $model['pricing']['completion'] ?? 0 ); + + $normalized[] = [ + 'id' => (string) $model['id'], + 'name' => (string) ( $model['name'] ?? $model['id'] ), + 'provider' => $name_parts[0] ?? 'unknown', + 'context_length' => (int) ( $model['context_length'] ?? 0 ), + 'input_price_per_m' => round( $prompt_price * 1_000_000, 4 ), + 'output_price_per_m' => round( $completion_price * 1_000_000, 4 ), + 'capabilities' => $this->derive_capabilities( $input_modalities, $output_modalities ), + 'deprecated' => false, + 'updated_at' => (int) ( $model['created'] ?? 0 ), + ]; + } + + usort( $normalized, static function ( array $a, array $b ): int { + return strcasecmp( $a['name'], $b['name'] ); + } ); + + return $normalized; + } + + /** + * Derive capability strings from input/output modality arrays. + * + * @param string[] $input_modalities e.g. ['text', 'image'] + * @param string[] $output_modalities e.g. ['text'] + * + * @return string[] Matched capability labels. Defaults to ['text→text']. + */ + private function derive_capabilities( array $input_modalities, array $output_modalities ): array { + $caps = []; + + foreach ( self::CAPABILITY_MAP as $label => $requirements ) { + $input_match = empty( array_diff( $requirements['in'], $input_modalities ) ); + $output_match = empty( array_diff( $requirements['out'], $output_modalities ) ); + if ( $input_match && $output_match ) { + $caps[] = $label; + } + } + + return empty( $caps ) ? [ 'text→text' ] : $caps; + } +} diff --git a/includes/services/class-openrouter-model-registry.php b/includes/services/class-openrouter-model-registry.php new file mode 100644 index 0000000..8dcb62a --- /dev/null +++ b/includes/services/class-openrouter-model-registry.php @@ -0,0 +1,226 @@ +option_name = $option_name; + $this->fetched_at_option = $option_name . '_fetched_at'; + $this->transient_name = $transient_name; + $this->endpoint = $endpoint; + $this->cache_ttl = $cache_ttl_seconds; + $this->idempotency_window = $idempotency_seconds; + $this->normalizer = new PRAutoBlogger_OpenRouter_Model_Normalizer(); + } + + /** {@inheritDoc} */ + public function get_models(): array { + return $this->load_payload(); + } + + /** {@inheritDoc} */ + public function get_models_with_capability( string $capability ): array { + return array_values( + array_filter( + $this->load_payload(), + static function ( array $model ) use ( $capability ): bool { + return in_array( $capability, $model['capabilities'] ?? [], true ); + } + ) + ); + } + + /** {@inheritDoc} */ + public function find_model( string $id ): ?array { + foreach ( $this->load_payload() as $model ) { + if ( ( $model['id'] ?? '' ) === $id ) { + return $model; + } + } + return null; + } + + /** {@inheritDoc} */ + public function refresh( bool $force = false ): array { + $fetched_at = $this->get_fetched_at(); + + if ( ! $force && $fetched_at > 0 && ( time() - $fetched_at ) < $this->idempotency_window ) { + $payload = $this->load_payload(); + return [ 'count' => count( $payload ), 'fetched_at' => $fetched_at ]; + } + + $raw_models = $this->fetch_from_api(); + if ( null === $raw_models ) { + $this->flag_stale(); + $stale = $this->load_from_option(); + return [ 'count' => count( $stale ), 'fetched_at' => $fetched_at ]; + } + + $normalized = $this->normalizer->normalize( $raw_models ); + $now = time(); + + $this->save_payload( $normalized, $now ); + $this->clear_stale_flag(); + + PRAutoBlogger_Logger::instance()->info( + sprintf( 'Model registry refreshed: %d models.', count( $normalized ) ), + self::LOG_CONTEXT + ); + + return [ 'count' => count( $normalized ), 'fetched_at' => $now ]; + } + + /** {@inheritDoc} */ + public function get_fetched_at(): int { + return (int) get_option( $this->fetched_at_option, 0 ); + } + + /** {@inheritDoc} */ + public function get_provider_name(): string { + return self::PROVIDER_ID; + } + + /** + * Load from transient (fast) falling back to option (durable). + * + * @return array + */ + private function load_payload(): array { + $cached = get_transient( $this->transient_name ); + if ( is_array( $cached ) && ! empty( $cached ) ) { + return $cached; + } + return $this->load_from_option(); + } + + /** @return array */ + private function load_from_option(): array { + $data = get_option( $this->option_name, [] ); + return is_array( $data ) ? $data : []; + } + + private function save_payload( array $payload, int $now ): void { + update_option( $this->option_name, $payload, false ); + update_option( $this->fetched_at_option, $now, false ); + set_transient( $this->transient_name, $payload, $this->cache_ttl ); + } + + /** + * Fetch the model list from the OpenRouter API with exponential-backoff retry. + * + * @return ?array Raw 'data' array, or null on failure. + */ + private function fetch_from_api(): ?array { + $max_retries = defined( 'PRAUTOBLOGGER_MAX_RETRIES' ) ? PRAUTOBLOGGER_MAX_RETRIES : 3; + $base_delay = defined( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS' ) ? PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS : 2; + + for ( $attempt = 1; $attempt <= $max_retries; $attempt++ ) { + $response = wp_remote_get( $this->endpoint, [ 'timeout' => self::HTTP_TIMEOUT ] ); + + if ( is_wp_error( $response ) ) { + PRAutoBlogger_Logger::instance()->warning( + sprintf( 'Model registry fetch attempt %d: %s', $attempt, $response->get_error_message() ), + self::LOG_CONTEXT + ); + $this->backoff( $attempt, $base_delay ); + continue; + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + $body = (string) wp_remote_retrieve_body( $response ); + + if ( $status >= 500 || 429 === $status ) { + PRAutoBlogger_Logger::instance()->warning( + sprintf( 'Model registry fetch attempt %d: HTTP %d', $attempt, $status ), + self::LOG_CONTEXT + ); + $this->backoff( $attempt, $base_delay ); + continue; + } + + if ( $status >= 400 ) { + PRAutoBlogger_Logger::instance()->error( + sprintf( 'Model registry: HTTP %d: %s', $status, substr( $body, 0, 300 ) ), + self::LOG_CONTEXT + ); + return null; + } + + $decoded = json_decode( $body, true ); + if ( ! is_array( $decoded ) || ! isset( $decoded['data'] ) || ! is_array( $decoded['data'] ) ) { + PRAutoBlogger_Logger::instance()->error( 'Model registry: unexpected response shape.', self::LOG_CONTEXT ); + return null; + } + + return $decoded['data']; + } + + PRAutoBlogger_Logger::instance()->error( + sprintf( 'Model registry fetch failed after %d attempts.', $max_retries ), + self::LOG_CONTEXT + ); + return null; + } + + private function backoff( int $attempt, int $base_delay ): void { + $delay = $base_delay * (int) pow( 2, $attempt - 1 ); + if ( $delay > 0 ) { + sleep( $delay ); + } + } + + private function flag_stale(): void { + update_option( self::ADMIN_NOTICE_FLAG, '1', false ); + PRAutoBlogger_Logger::instance()->warning( 'Model registry: serving stale data after refresh failure.', self::LOG_CONTEXT ); + } + + private function clear_stale_flag(): void { + delete_option( self::ADMIN_NOTICE_FLAG ); + } +} diff --git a/includes/services/interface-model-registry.php b/includes/services/interface-model-registry.php new file mode 100644 index 0000000..634f32b --- /dev/null +++ b/includes/services/interface-model-registry.php @@ -0,0 +1,94 @@ + Normalized model records, sorted by name ascending. + */ + public function get_models(): array; + + /** + * Return only models matching a given capability string. + * + * @param string $capability One of the standardized capability strings: + * 'text→text', 'text+image→text', 'text+audio→text', + * 'text→image', 'text→audio', 'text→video', + * 'text→embedding'. + * + * @return array Same record shape as get_models(), filtered. + */ + public function get_models_with_capability( string $capability ): array; + + /** + * Look up one model by its provider-qualified id (e.g. 'anthropic/claude-3.5-haiku'). + * + * @param string $id Model identifier. + * + * @return ?array Normalized model record, or null if not in the registry. + */ + public function find_model( string $id ): ?array; + + /** + * Fetch fresh data from the upstream source and replace the cached payload. + * + * When `$force` is false, implementations SHOULD skip the HTTP call if the + * payload is younger than a configured idempotency window (default 12h). + * + * Side effects: HTTP GET to the upstream API, wp_options write, transient set, + * log line on failure, admin notice flag on stale-and-failed. + * + * @param bool $force True to bypass the idempotency window (e.g. manual admin refresh). + * + * @return array{count: int, fetched_at: int} Number of models stored and unix timestamp. + */ + public function refresh( bool $force = false ): array; + + /** + * Unix timestamp of the last successful refresh. Zero if never refreshed. + * + * @return int + */ + public function get_fetched_at(): int; + + /** + * Machine-readable provider identifier (e.g. 'openrouter', 'cloudflare_workers_ai'). + * + * @return string + */ + public function get_provider_name(): string; +} diff --git a/tests/unit/Services/OpenRouterModelRegistryTest.php b/tests/unit/Services/OpenRouterModelRegistryTest.php new file mode 100644 index 0000000..fcafcef --- /dev/null +++ b/tests/unit/Services/OpenRouterModelRegistryTest.php @@ -0,0 +1,252 @@ +justReturn( true ); + Functions\when( 'delete_option' )->justReturn( true ); + Functions\when( 'is_wp_error' )->justReturn( false ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( '{"data":[]}' ); + } + + // ── Normalizer tests ──────────────────────────────────────── + + /** + * Normalizer correctly maps input+output modalities → capability strings. + */ + public function test_normalize_maps_capabilities(): void { + $normalizer = new \PRAutoBlogger_OpenRouter_Model_Normalizer(); + $raw = [ + [ + 'id' => 'anthropic/claude-3.5-haiku', + 'name' => 'Claude 3.5 Haiku', + 'context_length' => 200000, + 'pricing' => [ 'prompt' => '0.0000008', 'completion' => '0.000004' ], + 'architecture' => [ + 'input_modalities' => [ 'text', 'image' ], + 'output_modalities' => [ 'text' ], + ], + 'created' => 1700000000, + ], + ]; + + $result = $normalizer->normalize( $raw ); + + $this->assertCount( 1, $result ); + $model = $result[0]; + $this->assertSame( 'anthropic/claude-3.5-haiku', $model['id'] ); + $this->assertSame( 'Claude 3.5 Haiku', $model['name'] ); + $this->assertSame( 'anthropic', $model['provider'] ); + $this->assertSame( 200000, $model['context_length'] ); + $this->assertEqualsWithDelta( 0.80, $model['input_price_per_m'], 0.01 ); + $this->assertEqualsWithDelta( 4.00, $model['output_price_per_m'], 0.01 ); + $this->assertContains( 'text→text', $model['capabilities'] ); + $this->assertContains( 'text+image→text', $model['capabilities'] ); + } + + /** + * Unknown modalities degrade to text→text, never empty capabilities. + */ + public function test_normalize_unknown_modalities_degrade_to_text(): void { + $normalizer = new \PRAutoBlogger_OpenRouter_Model_Normalizer(); + $raw = [ + [ + 'id' => 'custom/mystery-model', + 'architecture' => [ + 'input_modalities' => [ 'brainwaves' ], + 'output_modalities' => [ 'hologram' ], + ], + ], + ]; + + $result = $normalizer->normalize( $raw ); + $this->assertSame( [ 'text→text' ], $result[0]['capabilities'] ); + } + + /** + * Records without 'id' are silently skipped. + */ + public function test_normalize_skips_invalid_records(): void { + $normalizer = new \PRAutoBlogger_OpenRouter_Model_Normalizer(); + $raw = [ + [ 'name' => 'orphan model without id' ], + 'not an array', + [ 'id' => 'valid/model', 'name' => 'Valid' ], + ]; + + $this->assertCount( 1, $normalizer->normalize( $raw ) ); + } + + /** + * Results are sorted alphabetically by name. + */ + public function test_normalize_sorts_by_name(): void { + $normalizer = new \PRAutoBlogger_OpenRouter_Model_Normalizer(); + $raw = [ + [ 'id' => 'z/zebra', 'name' => 'Zebra Model' ], + [ 'id' => 'a/alpha', 'name' => 'Alpha Model' ], + [ 'id' => 'm/mid', 'name' => 'Mid Model' ], + ]; + + $result = $normalizer->normalize( $raw ); + $this->assertSame( 'Alpha Model', $result[0]['name'] ); + $this->assertSame( 'Zebra Model', $result[2]['name'] ); + } + + // ── Registry tests ────────────────────────────────────────── + + /** + * Idempotency: refresh(false) with a fresh payload skips HTTP. + */ + public function test_refresh_skips_when_fresh(): void { + // Simulate: fetched 1 hour ago, idempotency window = 12h. + $this->stub_get_option( [ + self::OPTION . '_fetched_at' => time() - 3600, + self::OPTION => [ [ 'id' => 'cached/model' ] ], + 'prautoblogger_log_level' => 'info', + ] ); + + // wp_remote_get should NOT be called. + Functions\when( 'wp_remote_get' )->alias( function () { + throw new \RuntimeException( 'wp_remote_get should not be called' ); + } ); + + $registry = new \PRAutoBlogger_OpenRouter_Model_Registry( + self::OPTION, self::TRANSIENT, 'https://test.example.com/models', 86400, 43200 + ); + + $result = $registry->refresh( false ); + $this->assertSame( 1, $result['count'] ); + } + + /** + * refresh(true) bypasses idempotency and always fetches. + */ + public function test_refresh_force_fetches(): void { + $this->stub_get_option( [ + self::OPTION . '_fetched_at' => time() - 60, + self::OPTION => [], + 'prautoblogger_log_level' => 'info', + ] ); + + $api_body = wp_json_encode( [ + 'data' => [ + [ 'id' => 'fresh/model', 'name' => 'Fresh', 'architecture' => [ 'input_modalities' => [ 'text' ], 'output_modalities' => [ 'text' ] ] ], + ], + ] ); + + Functions\when( 'wp_remote_get' )->justReturn( [ 'response' => [ 'code' => 200 ] ] ); + Functions\when( 'wp_remote_retrieve_response_code' )->justReturn( 200 ); + Functions\when( 'wp_remote_retrieve_body' )->justReturn( $api_body ); + + $registry = new \PRAutoBlogger_OpenRouter_Model_Registry( + self::OPTION, self::TRANSIENT, 'https://test.example.com/models', 86400, 43200 + ); + + $result = $registry->refresh( true ); + $this->assertSame( 1, $result['count'] ); + } + + /** + * get_models_with_capability filters correctly. + */ + public function test_capability_filtering(): void { + $models = [ + [ 'id' => 'a', 'capabilities' => [ 'text→text' ] ], + [ 'id' => 'b', 'capabilities' => [ 'text+image→text', 'text→text' ] ], + [ 'id' => 'c', 'capabilities' => [ 'text→image' ] ], + ]; + + // Serve from transient. + Functions\when( 'get_transient' )->alias( function ( $key ) use ( $models ) { + return $key === self::TRANSIENT ? $models : false; + } ); + + $registry = new \PRAutoBlogger_OpenRouter_Model_Registry( + self::OPTION, self::TRANSIENT + ); + + $text_to_text = $registry->get_models_with_capability( 'text→text' ); + $this->assertCount( 2, $text_to_text ); + + $vision = $registry->get_models_with_capability( 'text+image→text' ); + $this->assertCount( 1, $vision ); + $this->assertSame( 'b', $vision[0]['id'] ); + } + + /** + * find_model returns null for unknown models. + */ + public function test_find_model_returns_null_for_unknown(): void { + Functions\when( 'get_transient' )->justReturn( [] ); + $this->stub_get_option( [ self::OPTION => [], 'prautoblogger_log_level' => 'info' ] ); + + $registry = new \PRAutoBlogger_OpenRouter_Model_Registry( self::OPTION, self::TRANSIENT ); + $this->assertNull( $registry->find_model( 'nonexistent/model' ) ); + } + + /** + * Stale-and-fetch-fails: serves option payload and flags admin notice. + */ + public function test_stale_fallback_on_fetch_failure(): void { + $cached_models = [ [ 'id' => 'stale/model', 'capabilities' => [ 'text→text' ] ] ]; + + $this->stub_get_option( [ + self::OPTION . '_fetched_at' => time() - 100000, + self::OPTION => $cached_models, + 'prautoblogger_log_level' => 'info', + ] ); + + // Simulate network failure. + $wp_error = new \stdClass(); + Functions\when( 'wp_remote_get' )->justReturn( $wp_error ); + Functions\when( 'is_wp_error' )->justReturn( true ); + $wp_error->get_error_message = function () { + return 'Connection timed out'; + }; + Functions\when( 'wp_remote_get' )->alias( function () { + $e = new \WP_Error_Stub(); + return $e; + } ); + + // Accept that update_option will be called for the stale flag. + Functions\when( 'update_option' )->justReturn( true ); + + $registry = new \PRAutoBlogger_OpenRouter_Model_Registry( + self::OPTION, self::TRANSIENT, 'https://test.example.com/models', 86400, 43200 + ); + + $result = $registry->refresh( true ); + $this->assertSame( 1, $result['count'] ); + } +} + +/** + * Minimal WP_Error stub for testing is_wp_error() branches. + */ +if ( ! class_exists( 'WP_Error_Stub' ) ) { + class WP_Error_Stub { + public function get_error_message(): string { + return 'Connection timed out'; + } + } +}