Files
prautoblogger/includes/services/class-openrouter-model-normalizer.php
peptiderepo be5c659999 feat(models): OpenRouter model registry + interface + cron wiring (model-picker commit 1) (#13)
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

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
2026-04-16 16:50:12 +07:00

115 lines
4.3 KiB
PHP

<?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;
}
}