* @author Maximum Software Inc * @license GPL-3.0-only * @link https://agenistrator.com/ * * SPDX-FileCopyrightText: 2025-2026 Alex Ruban * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc * SPDX-License-Identifier: GPL-3.0-only */ namespace Agenistrator; // Direct access prevention if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Model_Info class. */ class Model_Info { use Singleton; /** * API endpoint for model information */ private const API_URL = 'https://models.dev/api.json'; /** * Cache transient key */ private const CACHE_KEY = 'agenistrator_models_dev_cache'; /** * Cache TTL in seconds (24 hours) */ private const CACHE_TTL = 86400; /** * Default context window when model not found (tokens) */ public const DEFAULT_CONTEXT_WINDOW = 16000; /** * Default max output tokens */ public const DEFAULT_MAX_OUTPUT = 4000; /** * Minimum available input tokens to prevent system failures */ public const MINIMUM_AVAILABLE_INPUT = 1000; /** * Maximum fraction of context window to reserve for output (0.5 = 50%) */ private const MAX_OUTPUT_RESERVE_RATIO = 0.5; /** * In-memory cache of raw API data * @var array|null */ private $models_cache = null; /** * Sorted model IDs per provider for efficient prefix matching (longest first) * @var array */ private $sorted_ids_cache = array(); /** * Get available input tokens (context window minus output reserve, capped by rate limit) * * First tries to get limits from the provider (e.g., OpenRouter API provides this). * Falls back to models.dev for providers that don't supply limits (OpenAI, Anthropic, Ollama Cloud). * * The output reserve is capped at MAX_OUTPUT_RESERVE_RATIO of the context window * because models.dev's "output limit" represents maximum possible output, not a * fixed reservation. Without this cap, models where output >= context would return 0. * * @param LLM_Chat $chat_model The chat model instance * @return int Available input tokens (minimum MINIMUM_AVAILABLE_INPUT) * @since 1.0.0 */ public function get_available_input( LLM_Chat $chat_model ) { $provider = $chat_model->get_provider(); $model_id = $chat_model->get_model_id(); // Try provider-supplied limits first (authoritative for OpenRouter) $limits = $provider->get_model_limits( $model_id ); $context_window = $limits['context_length']; $output_limit = $limits['max_output_tokens']; // Fall back to models.dev for missing values if ( null === $context_window || null === $output_limit ) { $provider_key = $provider->get_models_dev_key(); $model_data = $this->find_model( $provider_key, $model_id ); $context_window = $context_window ?? $model_data['limit']['context'] ?? $this->get_default_context_window(); $output_limit = $output_limit ?? $model_data['limit']['output'] ?? $this->get_default_max_output(); } // Cap output reserve to prevent 0 available input when output >= context $max_reserve = (int) ( $context_window * self::MAX_OUTPUT_RESERVE_RATIO ); $output_reserve = min( $output_limit, $max_reserve ); $model_limit = $context_window - $output_reserve; // Apply provider rate limit cap if configured (null = no limit) $tpm_limit = $provider->get_max_tokens_per_minute(); if ( null !== $tpm_limit ) { $model_limit = min( $model_limit, $tpm_limit ); } return max( $model_limit, self::MINIMUM_AVAILABLE_INPUT ); } /** * Get default context window size from settings or fallback * * @return int Default context window size * @since 1.0.0 */ public function get_default_context_window() { return (int) Settings::get_instance()->get_config( 'default_context_window', self::DEFAULT_CONTEXT_WINDOW ); } /** * Get default max output tokens from settings or fallback * * @return int Default max output tokens * @since 1.0.0 */ public function get_default_max_output() { return (int) Settings::get_instance()->get_config( 'default_max_output_tokens', self::DEFAULT_MAX_OUTPUT ); } /** * Get model display name from models.dev * * @param string $provider_key The models.dev provider key (e.g., 'openai', 'anthropic') * @param string $model_id The model ID to look up * @return string Display name or model_id if not found * @since 1.0.0 */ public function get_display_name( $provider_key, $model_id ) { $model_data = $this->find_model( $provider_key, $model_id ); return $model_data['name'] ?? $model_id; } /** * Check if a model is compatible with chat (text input, text output, tool calling) * * @param string $provider_key The models.dev provider key (e.g., 'openai', 'anthropic') * @param string $model_id The model ID to check * @return bool|null True if compatible, false if not, null if model not found in models.dev * @since 1.0.0 */ public function is_chat_compatible( $provider_key, $model_id ) { $model_data = $this->find_model( $provider_key, $model_id ); if ( null === $model_data ) { return null; } $input_modalities = $model_data['modalities']['input'] ?? array(); $output_modalities = $model_data['modalities']['output'] ?? array(); $tool_call = $model_data['tool_call'] ?? false; return in_array( 'text', $input_modalities, true ) && in_array( 'text', $output_modalities, true ) && true === $tool_call; } /** * Check if a model supports the temperature sampling parameter * * @param string $provider_key The models.dev provider key (e.g., 'openai', 'anthropic') * @param string $model_id The model ID to check * @return bool|null True if supported, false if not, null if model not found in models.dev * @since 1.0.1 */ public function supports_temperature( $provider_key, $model_id ) { $model_data = $this->find_model( $provider_key, $model_id ); if ( null === $model_data ) { return null; } return true === ( $model_data['temperature'] ?? false ); } /** * Find model data by provider key and model ID * * @param string $provider_key The models.dev provider key (e.g., 'openai', 'anthropic') * @param string $model_id The model ID to find * @return array|null Model data or null if not found */ private function find_model( $provider_key, $model_id ) { if ( empty( $provider_key ) || empty( $model_id ) ) { return null; } $data = $this->get_models_data(); $models = $data[ $provider_key ]['models'] ?? null; if ( ! is_array( $models ) ) { return null; } // direct lookup if ( isset( $models[ $model_id ] ) ) { return $models[ $model_id ]; } // Fuzzy matching for versioned model IDs (e.g., claude-3-haiku-20240307 → claude-3-haiku) foreach ( $this->get_sorted_model_ids( $provider_key, $models ) as $id ) { if ( strncmp( $model_id, $id, strlen( $id ) ) === 0 || strncmp( $id, $model_id, strlen( $model_id ) ) === 0 ) { return $models[ $id ]; } } return null; } /** * Get sorted model IDs for a provider (longest first for best prefix matching) * * @param string $provider_key The provider key * @param array $models The models array * @return array Sorted model IDs */ private function get_sorted_model_ids( $provider_key, $models ) { if ( ! isset( $this->sorted_ids_cache[ $provider_key ] ) ) { $ids = array_keys( $models ); usort( $ids, fn( $a, $b ) => strlen( $b ) - strlen( $a ) ); $this->sorted_ids_cache[ $provider_key ] = $ids; } return $this->sorted_ids_cache[ $provider_key ]; } /** * Get models data from cache or API * * @return array Raw models data indexed by provider */ private function get_models_data() { if ( null !== $this->models_cache ) { return $this->models_cache; } $cached_json = get_transient( self::CACHE_KEY ); if ( is_string( $cached_json ) ) { $cached = json_decode( $cached_json, true ); if ( is_array( $cached ) ) { $this->models_cache = $cached; return $cached; } } $data = $this->fetch_from_api(); if ( ! empty( $data ) ) { set_transient( self::CACHE_KEY, wp_json_encode( $data ), self::CACHE_TTL ); $this->models_cache = $data; return $data; } return array(); } /** * Fetch model data from models.dev API * * @return array Models data or empty array on failure */ private function fetch_from_api() { $response = wp_remote_get( self::API_URL, array( 'timeout' => 10, 'user-agent' => 'Agenistrator/' . AGENISTRATOR_VERSION, 'headers' => array( 'Accept' => 'application/json' ), ) ); if ( is_wp_error( $response ) ) { return array(); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); return is_array( $data ) ? $data : array(); } /** * Clear the cached model data * @since 1.0.0 */ public function clear_cache() { $this->models_cache = null; $this->sorted_ids_cache = array(); delete_transient( self::CACHE_KEY ); } }