Files
prautoblogger/includes/services/class-open-router-model-normalizer.php
peptiderepo dc86376e8b style(phpcs): resolve 137 residual WordPress-Core violations
- Added phpcs:ignore for class file naming convention (79 violations)
  WordPress.Files.FileName.InvalidClassFileName is a known architectural
  issue — class files use short names without prautoblogger- prefix

- Added phpcs:ignore for ->prepare() table interpolation (56 violations)
  WordPress.DB.PreparedSQL.InterpolatedNotPrepared: dynamic table names
  (prab_generation_logs, prab_cost_logs) require interpolation; ->prepare()
  does not support table name placeholders, only column values

- Fixed file-level docblock positioning (2 violations)
  prautoblogger.php and uninstall.php: moved declare(strict_types=1) before
  file docblock per PHP best practices
2026-04-23 03:23:52 +00:00

140 lines
4.6 KiB
PHP

<?php
// phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
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-open-router-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 = array(
'text→text' => array(
'in' => array( 'text' ),
'out' => array( 'text' ),
),
'text+image→text' => array(
'in' => array( 'text', 'image' ),
'out' => array( 'text' ),
),
'text+audio→text' => array(
'in' => array( 'text', 'audio' ),
'out' => array( 'text' ),
),
'text→image' => array(
'in' => array( 'text' ),
'out' => array( 'image' ),
),
'text→audio' => array(
'in' => array( 'text' ),
'out' => array( 'audio' ),
),
'text→video' => array(
'in' => array( 'text' ),
'out' => array( 'video' ),
),
'text→embedding' => array(
'in' => array( 'text' ),
'out' => array( '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 = array();
foreach ( $raw_models as $model ) {
if ( ! is_array( $model ) || ! isset( $model['id'] ) ) {
continue;
}
$input_modalities = (array) ( $model['architecture']['input_modalities'] ?? array( 'text' ) );
$output_modalities = (array) ( $model['architecture']['output_modalities'] ?? array( '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[] = array(
'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 = array();
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 ) ? array( 'text→text' ) : $caps;
}
}