@@ -41,11 +41,146 @@ class RTG_AI {
4141 */
4242 const API_URL = 'https://api.anthropic.com/v1/messages ' ;
4343
44+ /**
45+ * Anthropic Models API endpoint (list).
46+ */
47+ const MODELS_API_URL = 'https://api.anthropic.com/v1/models ' ;
48+
49+ /**
50+ * Option key where the refreshed model list is cached.
51+ * Shape: [ 'fetched_at' => int, 'models' => [ [id => string, display_name => string], ... ] ]
52+ */
53+ const MODELS_OPTION = 'rtg_ai_models_cache ' ;
54+
4455 /**
4556 * Default model to use.
4657 */
4758 const DEFAULT_MODEL = 'claude-haiku-4-5-20251001 ' ;
4859
60+ /**
61+ * Fallback model list used when Anthropic has never been queried
62+ * (or the refresh failed). Order is the same order they'll render in
63+ * the admin dropdown.
64+ *
65+ * @return array[] Each entry has id and display_name.
66+ */
67+ public static function get_fallback_models () {
68+ return array (
69+ array ( 'id ' => 'claude-haiku-4-5-20251001 ' , 'display_name ' => 'Claude Haiku 4.5 (Fast, Low Cost) ' ),
70+ array ( 'id ' => 'claude-sonnet-4-20250514 ' , 'display_name ' => 'Claude Sonnet 4 (More Capable) ' ),
71+ );
72+ }
73+
74+ /**
75+ * Return the currently cached model list, or the fallback if none.
76+ * Used by the admin settings view and the save-handler allowlist.
77+ *
78+ * @return array { models: array[], fetched_at: int|null, source: 'api'|'fallback' }
79+ */
80+ public static function get_available_models () {
81+ $ cache = get_option ( self ::MODELS_OPTION , array () );
82+ if ( is_array ( $ cache ) && ! empty ( $ cache ['models ' ] ) ) {
83+ return array (
84+ 'models ' => $ cache ['models ' ],
85+ 'fetched_at ' => intval ( $ cache ['fetched_at ' ] ?? 0 ),
86+ 'source ' => 'api ' ,
87+ );
88+ }
89+ return array (
90+ 'models ' => self ::get_fallback_models (),
91+ 'fetched_at ' => null ,
92+ 'source ' => 'fallback ' ,
93+ );
94+ }
95+
96+ /**
97+ * Return just the list of valid model IDs, used as an allowlist
98+ * when saving the settings form.
99+ *
100+ * @return string[]
101+ */
102+ public static function get_allowed_model_ids () {
103+ $ data = self ::get_available_models ();
104+ return array_values ( array_filter ( array_map (
105+ static function ( $ m ) {
106+ return isset ( $ m ['id ' ] ) ? (string ) $ m ['id ' ] : '' ;
107+ },
108+ $ data ['models ' ]
109+ ) ) );
110+ }
111+
112+ /**
113+ * Fetch the current model list from Anthropic's /v1/models endpoint
114+ * and persist it to the cache option. Only models whose ID starts with
115+ * "claude-" are kept (the endpoint also returns deprecated/legacy entries).
116+ *
117+ * @return array|WP_Error Result array matching get_available_models(), or WP_Error.
118+ */
119+ public static function refresh_available_models () {
120+ $ api_key = self ::get_api_key ();
121+ if ( '' === $ api_key ) {
122+ return new WP_Error ( 'no_api_key ' , 'Set an Anthropic API key before refreshing the model list. ' );
123+ }
124+
125+ $ response = wp_remote_get ( self ::MODELS_API_URL . '?limit=100 ' , array (
126+ 'timeout ' => 15 ,
127+ 'headers ' => array (
128+ 'x-api-key ' => $ api_key ,
129+ 'anthropic-version ' => '2023-06-01 ' ,
130+ ),
131+ ) );
132+
133+ if ( is_wp_error ( $ response ) ) {
134+ error_log ( 'RTG AI models refresh error: ' . $ response ->get_error_message () );
135+ return new WP_Error ( 'api_error ' , 'Unable to reach Anthropic. ' . $ response ->get_error_message () );
136+ }
137+
138+ $ status = wp_remote_retrieve_response_code ( $ response );
139+ $ body = wp_remote_retrieve_body ( $ response );
140+ $ data = json_decode ( $ body , true );
141+
142+ if ( 401 === $ status ) {
143+ return new WP_Error ( 'api_auth ' , 'Anthropic rejected the API key. Check the key in settings. ' );
144+ }
145+ if ( 200 !== $ status || ! is_array ( $ data ) || empty ( $ data ['data ' ] ) ) {
146+ error_log ( 'RTG AI models refresh HTTP ' . $ status . ': ' . $ body );
147+ return new WP_Error ( 'api_error ' , 'Anthropic returned an unexpected response (HTTP ' . intval ( $ status ) . '). ' );
148+ }
149+
150+ $ models = array ();
151+ foreach ( $ data ['data ' ] as $ entry ) {
152+ if ( empty ( $ entry ['id ' ] ) || strpos ( $ entry ['id ' ], 'claude- ' ) !== 0 ) {
153+ continue ;
154+ }
155+ $ models [] = array (
156+ 'id ' => sanitize_text_field ( $ entry ['id ' ] ),
157+ 'display_name ' => sanitize_text_field ( $ entry ['display_name ' ] ?? $ entry ['id ' ] ),
158+ 'created_at ' => sanitize_text_field ( $ entry ['created_at ' ] ?? '' ),
159+ );
160+ }
161+
162+ if ( empty ( $ models ) ) {
163+ return new WP_Error ( 'empty_list ' , 'Anthropic returned no Claude models. ' );
164+ }
165+
166+ // Sort newest first using the created_at ISO timestamp Anthropic returns.
167+ usort ( $ models , static function ( $ a , $ b ) {
168+ return strcmp ( $ b ['created_at ' ] ?? '' , $ a ['created_at ' ] ?? '' );
169+ } );
170+
171+ $ payload = array (
172+ 'models ' => $ models ,
173+ 'fetched_at ' => time (),
174+ );
175+ update_option ( self ::MODELS_OPTION , $ payload , false );
176+
177+ return array (
178+ 'models ' => $ models ,
179+ 'fetched_at ' => $ payload ['fetched_at ' ],
180+ 'source ' => 'api ' ,
181+ );
182+ }
183+
49184 /**
50185 * Check whether AI recommendations are enabled and configured.
51186 *
0 commit comments