Compare commits

...

1 Commits

Author SHA1 Message Date
peptiderepo
350c4626c4 feat(models): OpenRouter model registry + interface + cron wiring (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 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
2026-04-16 09:26:13 +07:00
8 changed files with 741 additions and 3 deletions

View File

@@ -139,6 +139,12 @@ prautoblogger/
│ │ ├── class-cloudflare-image-support.php # Token, account, URL, and log helpers for the CF provider │ │ ├── class-cloudflare-image-support.php # Token, account, URL, and log helpers for the CF provider
│ │ └── (new providers go here — see CONVENTIONS.md) │ │ └── (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/ │ ├── frontend/
│ │ └── class-posts-widget.php # [prautoblogger_posts] shortcode + REST endpoint │ │ └── 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_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_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_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 ### 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) ### #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. 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) ### #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. 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.

View File

@@ -8,6 +8,18 @@ and this project uses [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
### Added ### 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 - **Image provider: Cloudflare Workers AI (FLUX.1 family).** First commit of
the image + Instagram A/B pipeline workstream. Ships the provider, its the image + Instagram A/B pipeline workstream. Ships the provider, its
pricing + validator helpers, four new settings fields in a new "Images" pricing + validator helpers, four new settings fields in a new "Images"

View File

@@ -26,6 +26,7 @@ class PRAutoBlogger_Autoloader {
'providers/', 'providers/',
'models/', 'models/',
'frontend/', 'frontend/',
'services/',
]; ];
/** /**

View File

@@ -18,11 +18,11 @@ declare(strict_types=1);
*/ */
class PRAutoBlogger { class PRAutoBlogger {
/**
* Whether the plugin has been initialized.
*/
private bool $initialized = false; 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. * Register all hooks and initialize the plugin.
* *
@@ -118,6 +118,11 @@ class PRAutoBlogger {
private function register_cron_hooks(): void { private function register_cron_hooks(): void {
add_action( 'prautoblogger_daily_generation', [ $this, 'on_daily_generation' ] ); add_action( 'prautoblogger_daily_generation', [ $this, 'on_daily_generation' ] );
add_action( 'prautoblogger_collect_metrics', [ $this, 'on_collect_metrics' ] ); 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. */ /** Register AJAX handlers for admin actions. */
@@ -218,6 +223,10 @@ class PRAutoBlogger {
* @return void * @return void
*/ */
public function on_daily_generation(): 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() ) { if ( ! $this->acquire_generation_lock() ) {
PRAutoBlogger_Logger::instance()->info( 'Daily generation skipped: already running (lock held).', 'scheduler' ); PRAutoBlogger_Logger::instance()->info( 'Daily generation skipped: already running (lock held).', 'scheduler' );
return; return;
@@ -361,6 +370,24 @@ class PRAutoBlogger {
return $result > 0; 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. */ /** Release the generation mutex. */
private function release_generation_lock(): void { private function release_generation_lock(): void {
global $wpdb; global $wpdb;

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
/**
* Normalizes raw OpenRouter model data into the standardized record shape.
*
* Extracted from PRAutoBlogger_OpenRouter_Model_Registry so both classes stay
* under the 300-line cap. The registry orchestrates fetch + cache; this helper
* owns the mapping logic.
*
* Triggered by: PRAutoBlogger_OpenRouter_Model_Registry::refresh().
* Dependencies: None — pure data transformation, no I/O.
*
* @see services/class-openrouter-model-registry.php — Sole consumer.
* @see interface-model-registry.php — Defines the normalized record shape.
* @see ARCHITECTURE.md — Capability vocabulary.
*/
class PRAutoBlogger_OpenRouter_Model_Normalizer {
/**
* Standardized capability vocabulary (CTO-locked). Derived from OpenRouter's
* `architecture.input_modalities` + `architecture.output_modalities`.
*
* @var array<string, array{in: string[], out: string[]}>
*/
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<int, array{
* id: string,
* name: string,
* provider: string,
* context_length: int,
* input_price_per_m: float,
* output_price_per_m: float,
* capabilities: string[],
* deprecated: bool,
* updated_at: int,
* }> 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;
}
}

View File

@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
/**
* OpenRouter model registry — fetches, normalizes, and caches the model list.
*
* All config is constructor-injected. No PRAUTOBLOGGER_* constants referenced
* inside this class — Phase 2 lifts it into a shared Composer package with
* zero internal edits.
*
* Storage: WP option (durable) fronted by a transient (fast reads, 24h TTL).
* On stale-and-fetch-fails, the option payload is served and a flag is set.
*
* Triggered by: `prautoblogger_refresh_model_registry` action, manual AJAX
* (commit 3), PRAutoBlogger_Activator::activate().
* Dependencies: wp_remote_get(), PRAutoBlogger_Logger,
* PRAutoBlogger_OpenRouter_Model_Normalizer.
*
* @see interface-model-registry.php — Interface.
* @see class-openrouter-model-normalizer.php — Normalization logic.
* @see class-prautoblogger.php — Hooks the refresh action.
* @see ARCHITECTURE.md — AI Model Registries section.
*/
class PRAutoBlogger_OpenRouter_Model_Registry implements PRAutoBlogger_Model_Registry_Interface {
private const PROVIDER_ID = 'openrouter';
private const LOG_CONTEXT = 'model-registry';
private const HTTP_TIMEOUT = 15;
private const ADMIN_NOTICE_FLAG = 'prautoblogger_model_registry_stale';
private string $option_name;
private string $fetched_at_option;
private string $transient_name;
private string $endpoint;
private int $cache_ttl;
private int $idempotency_window;
private PRAutoBlogger_OpenRouter_Model_Normalizer $normalizer;
/**
* @param string $option_name WP option for the durable payload.
* @param string $transient_name WP transient for fast reads.
* @param string $endpoint OpenRouter models API URL.
* @param int $cache_ttl_seconds Transient TTL (default 86400 = 24h).
* @param int $idempotency_seconds Skip refresh if younger than this (default 43200 = 12h).
*/
public function __construct(
string $option_name,
string $transient_name,
string $endpoint = 'https://openrouter.ai/api/v1/models',
int $cache_ttl_seconds = 86400,
int $idempotency_seconds = 43200
) {
$this->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<int, 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<int, 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 );
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/**
* Contract for a provider-specific AI model registry.
*
* Phase 1 (v1): single implementation for OpenRouter (`class-openrouter-model-registry.php`).
* Phase 3: a second implementation wraps Cloudflare Workers AI FLUX model data. A
* higher-level dispatcher iterates registered registries, calls each with the same
* capability, and unions the results — the per-registry interface stays unchanged.
*
* Implementations MUST:
* - Store the normalized registry as a WP option + transient cache layer.
* - Degrade gracefully when the source API is unreachable (serve last-good cache).
* - Never throw on public read methods — empty arrays are acceptable fallbacks.
* - Log refresh failures via PRAutoBlogger_Logger.
*
* Triggered by: Settings page field renderer (commit 2), AJAX refresh endpoint
* (commit 3), daily cron via the main plugin loader.
* Dependencies: None at interface level — implementations wrap an HTTP API.
*
* @see services/class-openrouter-model-registry.php — Default implementation.
* @see admin/fields/class-openrouter-model-field.php — Field renderer that queries this interface.
* @see ARCHITECTURE.md — AI Model Registries section.
*/
interface PRAutoBlogger_Model_Registry_Interface {
/**
* Return all models in the registry, normalized.
*
* @return array<int, array{
* id: string,
* name: string,
* provider: string,
* context_length: int,
* input_price_per_m: float,
* output_price_per_m: float,
* capabilities: string[],
* deprecated: bool,
* updated_at: int,
* }> 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<int, 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;
}

View File

@@ -0,0 +1,252 @@
<?php
/**
* Tests for PRAutoBlogger_OpenRouter_Model_Registry and its normalizer.
*
* Validates registry caching, idempotency, retry logic, stale fallback,
* capability filtering, and the normalize → find_model → get_models flow.
*
* @package PRAutoBlogger\Tests\Services
*/
namespace PRAutoBlogger\Tests\Services;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class OpenRouterModelRegistryTest extends BaseTestCase {
private const OPTION = 'test_openrouter_model_registry';
private const TRANSIENT = 'test_openrouter_model_registry_cache';
protected function setUp(): void {
parent::setUp();
Functions\when( 'update_option' )->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';
}
}
}