provider. Not autoloaded. */ const GONE_OPTION = 'csdt_model_feed_gone'; /** Set after a forced refetch, so a run of failures fetches at most once an hour. */ const FORCED_TRANSIENT = 'csdt_model_feed_forced'; /** Providers the plugins can call. Anything else in the feed is ignored. */ const PROVIDERS = array( 'anthropic', 'gemini', 'openai' ); /** Request features a model may be flagged with. Unknown flags are dropped. */ const CAPS = array( 'effort' ); /** Automatic roles every provider block must name. */ const ROLES = array( 'standard', 'deep', 'fast' ); /** * Callbacks that say whether their plugin uses AI on this site; any true opts in. * * A registry rather than a WordPress filter: a hook name shared by several plugins cannot * carry each plugin's own prefix, and WordPress.org's Plugin Check flags it. * * @var array */ private static $opt_ins = array(); /** * Register a plugin's opt-in check. * * @param callable $check Returns true when the plugin uses AI on this site. * @return void */ public static function register_opt_in( callable $check ): void { self::$opt_ins[] = $check; } /** * Whether any registered plugin opts in. * * @return bool */ private static function enabled(): bool { foreach ( self::$opt_ins as $check ) { if ( call_user_func( $check ) ) { return true; } } return false; } /** * The validated feed, or null when there is none. Fetches only when allowed to (see above). * * @return array{providers: array}>, auto: array}>, retired: array}|null */ public static function get(): ?array { static $memo = false; if ( false !== $memo ) { return $memo; } if ( ! self::enabled() ) { $memo = null; return null; } $cached = get_transient( self::TRANSIENT ); if ( is_array( $cached ) && isset( $cached['providers'] ) ) { $memo = $cached; return $memo; } if ( ! self::may_fetch() || get_transient( self::RETRY_TRANSIENT ) ) { $memo = null; return null; } $memo = self::fetch(); return $memo; } /** * Whether this request may make the network call. * * @return bool */ private static function may_fetch(): bool { if ( wp_doing_cron() ) { return true; } return is_admin() && ! wp_doing_ajax(); } /** * Fetch, validate and cache the feed. * * @return array|null */ private static function fetch(): ?array { $resp = wp_safe_remote_get( self::URL, array( 'timeout' => 3, 'redirection' => 0, 'user-agent' => self::USER_AGENT, 'headers' => array( 'Accept' => 'application/json' ), ) ); $code = (int) wp_remote_retrieve_response_code( $resp ); $feed = 200 === $code ? self::validate( json_decode( (string) wp_remote_retrieve_body( $resp ), true ) ) : null; if ( null === $feed ) { set_transient( self::RETRY_TRANSIENT, 1, HOUR_IN_SECONDS ); return null; } set_transient( self::TRANSIENT, $feed, $feed['ttl'] ); return $feed; } /** * Validate a decoded feed. Pure, so it can be tested without WordPress' HTTP API. * * A provider block with anything wrong in it is dropped whole, so the caller uses its own * list for that provider rather than a half-applied one. * * @param mixed $raw Decoded JSON. * @return array|null Null when no provider survives. */ public static function validate( $raw ): ?array { if ( ! is_array( $raw ) || 1 !== ( $raw['schema'] ?? null ) || ! is_array( $raw['providers'] ?? null ) ) { return null; } $providers = array(); foreach ( self::PROVIDERS as $provider ) { $block = self::validate_provider( $raw['providers'][ $provider ] ?? null ); if ( null !== $block ) { $providers[ $provider ] = $block; } } if ( ! $providers ) { return null; } $offered = array(); foreach ( $providers as $block ) { $offered += $block['models']; } $retired = array(); foreach ( (array) ( $raw['retired'] ?? array() ) as $from => $to ) { // A retirement onto a model that is not offered, or of a model that is, would repair // a saved value into a broken one. if ( is_string( $from ) && is_string( $to ) && self::valid_id( $from ) && isset( $offered[ $to ] ) && ! isset( $offered[ $from ] ) ) { $retired[ $from ] = $to; } } // Between a day and a week, whatever the feed asks for. $ttl = is_int( $raw['ttl'] ?? null ) ? $raw['ttl'] : WEEK_IN_SECONDS; $ttl = max( DAY_IN_SECONDS, min( WEEK_IN_SECONDS, $ttl ) ); return array( 'providers' => $providers, 'retired' => $retired, 'ttl' => $ttl, ); } /** * Validate one provider's block. * * @param mixed $block Raw block. * @return array|null */ private static function validate_provider( $block ): ?array { if ( ! is_array( $block ) || ! is_array( $block['models'] ?? null ) || ! is_array( $block['auto'] ?? null ) ) { return null; } if ( ! $block['models'] || count( $block['models'] ) > 30 ) { return null; } $models = array(); foreach ( $block['models'] as $m ) { $id = is_array( $m ) && is_string( $m['id'] ?? null ) ? $m['id'] : ''; $label = is_array( $m ) && is_string( $m['label'] ?? null ) ? sanitize_text_field( $m['label'] ) : ''; $note = is_array( $m ) && is_string( $m['note'] ?? null ) ? sanitize_text_field( $m['note'] ) : ''; if ( ! self::valid_id( $id ) || isset( $models[ $id ] ) || '' === $label || strlen( $label ) > 80 || strlen( $note ) > 60 ) { return null; } $caps = array_values( array_intersect( self::CAPS, (array) ( $m['caps'] ?? array() ) ) ); $models[ $id ] = array( 'label' => $label, 'note' => $note, 'caps' => $caps, ); } $auto = array(); foreach ( self::ROLES as $role ) { $id = $block['auto'][ $role ] ?? ''; if ( ! is_string( $id ) || ! isset( $models[ $id ] ) ) { return null; } $auto[ $role ] = $id; } return array( 'models' => $models, 'auto' => $auto, ); } /** * The shape a model id must have before it goes anywhere near a URL or a request body. * * @param string $id Model id. * @return bool */ public static function valid_id( string $id ): bool { return 1 === preg_match( '/^[a-z0-9][a-z0-9.\-]{1,80}$/', $id ); } /** * One provider's models as id => display label, e.g. "GPT-6 Luna (fastest, cheapest)". * * @param string $provider Provider key. * @return array|null Null when the feed has no list for this provider. */ public static function models( string $provider ): ?array { $feed = self::get(); if ( null === $feed || ! isset( $feed['providers'][ $provider ] ) ) { return null; } $out = array(); foreach ( $feed['providers'][ $provider ]['models'] as $id => $m ) { $out[ $id ] = '' !== $m['note'] ? $m['label'] . ' (' . $m['note'] . ')' : $m['label']; } return $out; } /** * The model a provider's Automatic role resolves to. * * @param string $provider Provider key. * @param string $role standard | deep | fast. * @return string|null */ public static function auto( string $provider, string $role ): ?string { $feed = self::get(); return $feed['providers'][ $provider ]['auto'][ $role ] ?? null; } /** * Plain label of a model, without its note. * * @param string $id Model id. * @return string|null */ public static function label( string $id ): ?string { $feed = self::get(); foreach ( (array) ( $feed['providers'] ?? array() ) as $block ) { if ( isset( $block['models'][ $id ] ) ) { return $block['models'][ $id ]['label']; } } return null; } /** * Whether a model is flagged with a request feature. * * @param string $id Model id. * @param string $cap Feature, e.g. 'effort'. * @return bool|null Null when the feed does not list the model. */ public static function has_cap( string $id, string $cap ): ?bool { $feed = self::get(); foreach ( (array) ( $feed['providers'] ?? array() ) as $block ) { if ( isset( $block['models'][ $id ] ) ) { return in_array( $cap, $block['models'][ $id ]['caps'], true ); } } return null; } /** * Retired model => replacement, from the feed. Empty without one. * * @return array */ public static function retired(): array { $feed = self::get(); $retired = (array) ( $feed['retired'] ?? array() ); foreach ( self::gone() as $id => $provider ) { if ( ! isset( $retired[ $id ] ) ) { $to = self::replacement( $provider, $id ); if ( null !== $to ) { $retired[ $id ] = $to; } } } return $retired; } /** * Models a provider has told this site are gone, id => provider. * * @return array */ public static function gone(): array { $gone = get_option( self::GONE_OPTION, array() ); return is_array( $gone ) ? $gone : array(); } /** * Whether a provider's error says the model itself is gone, rather than the request or the * key being wrong. Anthropic: 404 not_found_error "model: x". OpenAI: "The model `x` does * not exist". Gemini: 404 "models/x is not found for API version v1beta", or a 400 naming * the model as no longer available. * * @param int $status HTTP status, 0 when only the message is known. * @param string $text The provider's error message or body. * @return bool */ public static function looks_like_model_gone( int $status, string $text ): bool { if ( 0 !== $status && 404 !== $status && 400 !== $status ) { return false; } if ( 0 === $status && ! preg_match( '/\b(404|400)\b/', $text ) ) { return false; } return 1 === preg_match( '/\bmodels?\b.{0,120}(not[ _]found|does not exist|no longer (available|supported)|has been (deprecated|retired|shut down)|is not supported|was retired)|not_found_error.{0,40}model|\b404\b.{0,30}\bmodel:/is', $text ) || ( 404 === $status && 1 === preg_match( '/^\s*model:\s*\S+\s*$/i', $text ) ); } /** * A provider said this model is gone: remember it, refetch the list now (at most hourly), * and say what to call instead, or null when there is nothing better to offer. * * @param string $provider Provider key. * @param string $model The model id that failed. * @return string|null */ public static function model_gone( string $provider, string $model ): ?string { if ( ! in_array( $provider, self::PROVIDERS, true ) || ! self::valid_id( $model ) ) { return null; } $gone = self::gone(); $gone[ $model ] = $provider; // Bounded: a site cannot grow this without limit from one misconfigured custom id. $gone = array_slice( $gone, -50, null, true ); update_option( self::GONE_OPTION, $gone, false ); if ( self::enabled() && ! get_transient( self::FORCED_TRANSIENT ) ) { set_transient( self::FORCED_TRANSIENT, 1, HOUR_IN_SECONDS ); delete_transient( self::TRANSIENT ); delete_transient( self::RETRY_TRANSIENT ); self::fetch(); } return self::replacement( $provider, $model ); } /** * The model to use instead of a gone one: CloudScale's own retirement for it, else the * provider's Automatic standard model, never the gone model itself or another gone one. * * @param string $provider Provider key. * @param string $model Gone model id. * @return string|null */ private static function replacement( string $provider, string $model ): ?string { $fresh = get_transient( self::TRANSIENT ); $feed = is_array( $fresh ) && isset( $fresh['providers'] ) ? $fresh : null; $gone = self::gone(); foreach ( array( $feed['retired'][ $model ] ?? null, $feed['providers'][ $provider ]['auto']['standard'] ?? null ) as $to ) { if ( is_string( $to ) && $to !== $model && ! isset( $gone[ $to ] ) ) { return $to; } } return null; } } }