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
This commit is contained in:
252
tests/unit/Services/OpenRouterModelRegistryTest.php
Normal file
252
tests/unit/Services/OpenRouterModelRegistryTest.php
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user