Skip to content

Commit 8cbe399

Browse files
authored
Merge pull request #255 from RivianTrackr/claude/ai-model-refresh
Add AI model list refresh from Anthropic
2 parents f74ac8d + c28365e commit 8cbe399

7 files changed

Lines changed: 272 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
77
## [1.45.0] - 2026-04-15
88

99
Plugin review sweep — security hardening, performance, and accessibility fixes
10-
across the PHP backend and JS frontend. No feature changes, no schema changes.
10+
across the PHP backend and JS frontend. One small admin feature (AI model list
11+
refresh) is also included. No schema changes.
12+
13+
### Added
14+
- **AI model list refresh** — The settings page now has a "Refresh from
15+
Anthropic" button next to the AI model dropdown. Clicking it calls
16+
Anthropic's `GET /v1/models` endpoint, caches the result in the
17+
`rtg_ai_models_cache` option, and rebuilds the dropdown in place without a
18+
page reload. The save-handler allowlist also reads from this cached list, so
19+
new Claude models become selectable immediately after refreshing — no code
20+
changes required. Previously-saved models that have been deprecated are
21+
preserved in the dropdown with a `(saved — not in current list)` suffix.
22+
(`includes/class-rtg-ai.php`, `includes/class-rtg-ajax.php`,
23+
`includes/class-rtg-admin.php`, `admin/views/settings.php`,
24+
`admin/js/admin-scripts.js`)
1125

1226
### Security
1327
- **AI API key can live in `wp-config.php`** — Define `RTG_ANTHROPIC_API_KEY` to

admin/js/admin-scripts.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,58 @@
497497
}
498498
}
499499

500+
// --- AI model list refresh ---
501+
// Fetches the current Claude model list from Anthropic via an admin-ajax
502+
// endpoint and rebuilds the <select> in place without a page reload.
503+
$('#rtg_refresh_ai_models').on('click', function(e) {
504+
e.preventDefault();
505+
var $btn = $(this);
506+
var $select = $('#rtg_ai_model');
507+
var $status = $('#rtg_refresh_ai_models_status');
508+
var nonce = $btn.data('nonce');
509+
var previousValue = $select.val();
510+
511+
if ($btn.prop('disabled')) return;
512+
$btn.prop('disabled', true);
513+
$status.text('Refreshing…').css('color', 'var(--rtg-text-muted)');
514+
515+
$.post(ajaxurl, {
516+
action: 'rtg_refresh_ai_models',
517+
nonce: nonce
518+
}).done(function(resp) {
519+
if (!resp || !resp.success || !resp.data || !resp.data.models) {
520+
var msg = (resp && resp.data) ? resp.data : 'Refresh failed.';
521+
$status.text(msg).css('color', '#c41e3a');
522+
return;
523+
}
524+
var models = resp.data.models;
525+
$select.empty();
526+
models.forEach(function(m) {
527+
var opt = document.createElement('option');
528+
opt.value = m.id;
529+
opt.textContent = m.display_name || m.id;
530+
if (m.id === previousValue) opt.selected = true;
531+
$select.append(opt);
532+
});
533+
// If the previously-saved model is gone from the list, keep it
534+
// visible so the user can see what they had.
535+
if (previousValue && models.every(function(m) { return m.id !== previousValue; })) {
536+
var orphan = document.createElement('option');
537+
orphan.value = previousValue;
538+
orphan.textContent = previousValue + ' (saved — not in current list)';
539+
orphan.selected = true;
540+
$select.prepend(orphan);
541+
}
542+
$status.text('Refreshed just now (' + models.length + ' models)').css('color', 'var(--rtg-text-muted)');
543+
}).fail(function(xhr) {
544+
var msg = 'Network error.';
545+
if (xhr && xhr.responseJSON && xhr.responseJSON.data) msg = xhr.responseJSON.data;
546+
$status.text(msg).css('color', '#c41e3a');
547+
}).always(function() {
548+
$btn.prop('disabled', false);
549+
});
550+
});
551+
500552
// --- JSON Feed URL copy button ---
501553
$('#rtg-copy-feed-url').on('click', function() {
502554
var $input = $('#rtg-feed-url');

admin/js/admin-scripts.min.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

admin/views/settings.php

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,20 @@
261261
<?php
262262
$ai_enabled = ! empty( $settings['ai_enabled'] );
263263
$ai_api_key = $settings['ai_api_key'] ?? '';
264-
$ai_model = $settings['ai_model'] ?? 'claude-haiku-4-5-20251001';
264+
$ai_model = $settings['ai_model'] ?? RTG_AI::DEFAULT_MODEL;
265+
$ai_models_data = RTG_AI::get_available_models();
266+
$ai_model_list = $ai_models_data['models'];
267+
$ai_models_fetched_at = $ai_models_data['fetched_at'];
268+
$ai_models_from_api = 'api' === $ai_models_data['source'];
269+
// If the saved model isn't in the list (e.g. Anthropic deprecated it),
270+
// prepend it so the select still renders the user's stored choice.
271+
$ids_in_list = array_column( $ai_model_list, 'id' );
272+
if ( $ai_model && ! in_array( $ai_model, $ids_in_list, true ) ) {
273+
array_unshift( $ai_model_list, array(
274+
'id' => $ai_model,
275+
'display_name' => $ai_model . ' (saved — not in current list)',
276+
) );
277+
}
265278
$ai_rate_limit = $settings['ai_rate_limit'] ?? 10;
266279
$ai_key_masked = $ai_api_key ? str_repeat( '*', max( 0, min( strlen( $ai_api_key ) - 8, 20 ) ) ) . substr( $ai_api_key, -8 ) : '';
267280
?>
@@ -296,11 +309,28 @@
296309
<div class="rtg-field-label-row">
297310
<label class="rtg-field-label" for="rtg_ai_model">AI Model</label>
298311
</div>
299-
<p class="rtg-field-description">Claude Haiku is fast and cost-effective (recommended). Claude Sonnet is more capable but slower and costs more per query.</p>
300-
<select id="rtg_ai_model" name="rtg_ai_model" style="min-width: 280px;">
301-
<option value="claude-haiku-4-5-20251001" <?php selected( $ai_model, 'claude-haiku-4-5-20251001' ); ?>>Claude Haiku 4.5 (Fast, Low Cost)</option>
302-
<option value="claude-sonnet-4-20250514" <?php selected( $ai_model, 'claude-sonnet-4-20250514' ); ?>>Claude Sonnet 4 (More Capable)</option>
303-
</select>
312+
<p class="rtg-field-description">Claude Haiku is fast and cost-effective (recommended). Claude Sonnet is more capable but slower and costs more per query. Use <strong>Refresh</strong> to pull the latest list of Claude models directly from Anthropic.</p>
313+
<div class="rtg-ai-model-row" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
314+
<select id="rtg_ai_model" name="rtg_ai_model" style="min-width: 320px;">
315+
<?php foreach ( $ai_model_list as $_m ) : ?>
316+
<option value="<?php echo esc_attr( $_m['id'] ); ?>" <?php selected( $ai_model, $_m['id'] ); ?>>
317+
<?php echo esc_html( $_m['display_name'] ); ?>
318+
</option>
319+
<?php endforeach; ?>
320+
</select>
321+
<button type="button" id="rtg_refresh_ai_models" class="button" data-nonce="<?php echo esc_attr( wp_create_nonce( 'rtg_admin_nonce' ) ); ?>">
322+
<span class="dashicons dashicons-update" style="vertical-align:middle;line-height:inherit;"></span>
323+
Refresh from Anthropic
324+
</button>
325+
<span id="rtg_refresh_ai_models_status" class="rtg-ai-model-status" style="font-size:12px;color:var(--rtg-text-muted);">
326+
<?php if ( $ai_models_from_api && $ai_models_fetched_at ) : ?>
327+
Last refreshed <?php echo esc_html( human_time_diff( $ai_models_fetched_at, current_time( 'timestamp' ) ) ); ?> ago
328+
(<?php echo count( $ai_model_list ); ?> models)
329+
<?php else : ?>
330+
Using built-in defaults — click Refresh to fetch the current list.
331+
<?php endif; ?>
332+
</span>
333+
</div>
304334
</div>
305335
<div class="rtg-field-row" style="border-bottom: none;">
306336
<div class="rtg-field-label-row">

includes/class-rtg-admin.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -766,13 +766,16 @@ private function handle_settings_save() {
766766

767767
// AI settings.
768768
$ai_api_key = sanitize_text_field( wp_unslash( $_POST['rtg_ai_api_key'] ?? '' ) );
769-
$ai_model = sanitize_text_field( $_POST['rtg_ai_model'] ?? 'claude-haiku-4-5-20251001' );
769+
$ai_model = sanitize_text_field( $_POST['rtg_ai_model'] ?? RTG_AI::DEFAULT_MODEL );
770770
$ai_rate_limit = intval( $_POST['rtg_ai_rate_limit'] ?? 10 );
771771
$ai_rate_limit = max( 1, min( 60, $ai_rate_limit ) );
772772

773-
$allowed_models = array( 'claude-haiku-4-5-20251001', 'claude-sonnet-4-20250514' );
774-
if ( ! in_array( $ai_model, $allowed_models, true ) ) {
775-
$ai_model = 'claude-haiku-4-5-20251001';
773+
// Allowlist the model against the cached Anthropic list (or fallback
774+
// when the admin hasn't refreshed it yet). Falling back to the first
775+
// available entry keeps the setting valid if Anthropic removes a model.
776+
$allowed_models = RTG_AI::get_allowed_model_ids();
777+
if ( empty( $allowed_models ) || ! in_array( $ai_model, $allowed_models, true ) ) {
778+
$ai_model = ! empty( $allowed_models ) ? $allowed_models[0] : RTG_AI::DEFAULT_MODEL;
776779
}
777780

778781
// Preserve existing API key if the field is left blank (masked).

includes/class-rtg-ai.php

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*

includes/class-rtg-ajax.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ public function __construct() {
114114
add_action( 'wp_ajax_rtg_ai_recommend', array( $this, 'ai_recommend' ) );
115115
add_action( 'wp_ajax_nopriv_rtg_ai_recommend', array( $this, 'ai_recommend' ) );
116116

117+
// AI model list refresh — admin only.
118+
add_action( 'wp_ajax_rtg_refresh_ai_models', array( $this, 'refresh_ai_models' ) );
119+
117120
// Roamer sync — admin only.
118121
add_action( 'wp_ajax_rtg_roamer_sync_now', array( $this, 'roamer_sync_now' ) );
119122
add_action( 'wp_ajax_rtg_roamer_assign', array( $this, 'roamer_assign' ) );
@@ -867,6 +870,29 @@ public function ai_recommend() {
867870
wp_send_json_success( $result );
868871
}
869872

873+
/**
874+
* Refresh the cached AI model list from Anthropic's /v1/models endpoint.
875+
* Admin-only. Returns the same shape as RTG_AI::get_available_models().
876+
*/
877+
public function refresh_ai_models() {
878+
if ( ! check_ajax_referer( 'rtg_admin_nonce', 'nonce', false ) ) {
879+
$this->log_security_event( 'nonce_fail', __METHOD__ );
880+
wp_send_json_error( 'Security check failed.' );
881+
}
882+
883+
if ( ! current_user_can( 'manage_options' ) ) {
884+
wp_send_json_error( 'Unauthorized.' );
885+
}
886+
887+
$result = RTG_AI::refresh_available_models();
888+
889+
if ( is_wp_error( $result ) ) {
890+
wp_send_json_error( $result->get_error_message() );
891+
}
892+
893+
wp_send_json_success( $result );
894+
}
895+
870896
/**
871897
* Run the affiliate link health check on demand.
872898
* Requires manage_options capability.

0 commit comments

Comments
 (0)