7 * DAY_IN_SECONDS, 'display' => __('週1回(AIモデル一覧の更新用)', 'aio-agent-article-generator'), ); return $schedules; } /** * 週次CRONを登録(冪等) * * @param int|null $first_delay 初回実行までの秒数(既定1時間) */ public static function schedule_refresh($first_delay = null) { // 有効化直後に一斉発火させない(過去にCRON集中で顧客サイトのWAFに引っかかった経緯がある) $delay = (null === $first_delay) ? HOUR_IN_SECONDS : max(60, intval($first_delay)); wp_clear_scheduled_hook(self::CRON_HOOK); wp_schedule_event(time() + $delay, self::CRON_SCHEDULE, self::CRON_HOOK); } /** * 週次CRONを解除 */ public static function unschedule_refresh() { wp_clear_scheduled_hook(self::CRON_HOOK); } // ========================================================================= // 既定モデル(第3層: 最後の砦) // ========================================================================= /** * コード内蔵の既定モデル一覧を取得 * * @param string $provider claude|gemini * @return array モデルID => 表示名 */ public static function get_builtin_models($provider) { if ('claude' === $provider) { return class_exists('AAGL_AI_Integration') ? AAGL_AI_Integration::CLAUDE_MODELS : array(); } if ('gemini' === $provider) { return class_exists('AAGL_AI_Integration') ? AAGL_AI_Integration::GEMINI_MODELS : array(); } return array(); } /** * プロバイダごとの既定モデルIDを取得 * * @param string $provider claude|gemini * @return string */ public static function get_default_model($provider) { if ('claude' === $provider) { return defined('AAGL_DEFAULT_CLAUDE_MODEL') ? AAGL_DEFAULT_CLAUDE_MODEL : 'claude-sonnet-4-6'; } if ('gemini' === $provider) { return defined('AAGL_DEFAULT_GEMINI_MODEL') ? AAGL_DEFAULT_GEMINI_MODEL : 'gemini-2.5-flash'; } return ''; } // ========================================================================= // カタログ(第2層: API取得したモデル一覧) // ========================================================================= /** * 保存済みカタログを取得 * * @param string $provider claude|gemini * @return array {fetched_at:int, models:array, error:string} */ public static function get_catalog($provider) { $catalog = get_option(self::OPTION_CATALOG, array()); if (!is_array($catalog) || !isset($catalog[$provider]) || !is_array($catalog[$provider])) { return array('fetched_at' => 0, 'models' => array(), 'error' => ''); } $entry = $catalog[$provider]; return array( 'fetched_at' => intval($entry['fetched_at'] ?? 0), 'models' => is_array($entry['models'] ?? null) ? $entry['models'] : array(), 'error' => (string) ($entry['error'] ?? ''), ); } /** * カタログが判定に使えるか(中身があり、期限内か) * * @param string $provider claude|gemini * @return bool */ public static function is_catalog_usable($provider) { $catalog = self::get_catalog($provider); if (empty($catalog['models'])) { return false; } return (time() - $catalog['fetched_at']) < self::CATALOG_TTL; } /** * 設定画面のプルダウンに出すモデル一覧 * * カタログが使える場合はカタログ、そうでなければ既定リストを返す。 * 現在設定中のモデルが一覧に無い場合でも選択肢として残す * (消えたモデルを選択中でも、保存操作で勝手に別モデルへ書き換わらないようにする) * * @param string $provider claude|gemini * @return array モデルID => 表示名 */ public static function get_models($provider) { $models = self::is_catalog_usable($provider) ? self::get_catalog($provider)['models'] : self::get_builtin_models($provider); // 既定モデルは常に選べるようにしておく(一覧取得失敗時の退避先) $default = self::get_default_model($provider); if ($default && !isset($models[$default])) { $builtin = self::get_builtin_models($provider); $models[$default] = $builtin[$default] ?? $default; } return $models; } /** * そのモデルが現在利用可能と判断できるか * * カタログが使えないときは「判定不能」として true を返す。 * 判定できないことを理由にモデルを弾くと、通信できない環境で * すべての生成が止まってしまうため、迷ったら通す側に倒す。 * * @param string $provider claude|gemini * @param string $model モデルID * @return bool */ public static function is_model_available($provider, $model) { if (empty($model)) { return false; } if (!self::is_catalog_usable($provider)) { return true; // 判定不能 → 通す } $catalog = self::get_catalog($provider); return isset($catalog['models'][$model]); } // ========================================================================= // モデル解決(3層フォールバック) // ========================================================================= /** * 実際にAPIへ送るモデルIDを決定する * * 優先順位: 手入力 > 設定プルダウン値(利用可能なもの) > 既定モデル * * @param string $provider claude|gemini * @return string */ public static function get_configured_model($provider) { $ai_settings = self::get_raw_ai_settings(); // 第1層: 手入力されたモデルID(明示的な意思なので最優先) $custom = sanitize_text_field($ai_settings[$provider . '_model_custom'] ?? ''); if ('' !== $custom) { return $custom; } // 第2層: 設定プルダウンの値 $selected = sanitize_text_field($ai_settings[$provider . '_model'] ?? ''); if ('' !== $selected) { if (self::is_catalog_usable($provider)) { // カタログで存在確認できるものだけ通す if (self::is_model_available($provider, $selected)) { return $selected; } } elseif (isset(self::get_builtin_models($provider)[$selected])) { // カタログが無いときは従来どおり内蔵リストで検証 return $selected; } } // 第3層: 既定モデル return self::get_default_model($provider); } /** * 404発生時に試す代替モデルを1つ返す * * @param string $provider claude|gemini * @param string $failed_model 失敗したモデルID * @return string 代替モデルID(見つからなければ空文字) */ public static function get_fallback_model($provider, $failed_model) { // まず既定モデル $default = self::get_default_model($provider); if ($default && $default !== $failed_model) { return $default; } // 既定モデル自体が失敗している場合は一覧から別のものを選ぶ foreach (array_keys(self::get_models($provider)) as $model_id) { if ($model_id !== $failed_model) { return $model_id; } } return ''; } /** * 反対側のプロバイダ名を返す * * @param string $provider claude|gemini * @return string */ public static function other_provider($provider) { return ('claude' === $provider) ? 'gemini' : 'claude'; } /** * プロバイダ横断フォールバックが有効か * * @return bool */ public static function is_provider_fallback_enabled() { $ai_settings = self::get_raw_ai_settings(); // 既定は有効(記事が止まることを防ぐのが本機能の目的のため) if (!array_key_exists('provider_fallback', $ai_settings)) { return true; } return !empty($ai_settings['provider_fallback']); } // ========================================================================= // API取得 // ========================================================================= /** * 指定プロバイダのモデル一覧をAPIから取得してキャッシュに保存 * * @param string $provider claude|gemini * @return array {success:bool, count:int, error:string} */ public static function refresh($provider) { if (!in_array($provider, self::PROVIDERS, true)) { return array('success' => false, 'count' => 0, 'error' => '未対応のプロバイダです'); } $api_key = AAGL_Utilities::get_api_key($provider . '_api_key'); if (empty($api_key)) { return array('success' => false, 'count' => 0, 'error' => 'APIキーが設定されていません'); } $result = ('claude' === $provider) ? self::fetch_claude_models($api_key) : self::fetch_gemini_models($api_key); $catalog = get_option(self::OPTION_CATALOG, array()); if (!is_array($catalog)) { $catalog = array(); } if ($result['success'] && !empty($result['models'])) { $catalog[$provider] = array( 'fetched_at' => time(), 'models' => $result['models'], 'error' => '', ); update_option(self::OPTION_CATALOG, $catalog, false); AAGL_Utilities::log(sprintf('Model catalog updated (%s): %d models', $provider, count($result['models']))); return array('success' => true, 'count' => count($result['models']), 'error' => ''); } // 取得失敗時は既存キャッシュを壊さず、エラーだけ記録する $existing = self::get_catalog($provider); $catalog[$provider] = array( 'fetched_at' => $existing['fetched_at'], 'models' => $existing['models'], 'error' => $result['error'], ); update_option(self::OPTION_CATALOG, $catalog, false); AAGL_Utilities::log(sprintf('Model catalog refresh failed (%s): %s', $provider, $result['error']), 'warning'); return array('success' => false, 'count' => 0, 'error' => $result['error']); } /** * Claude: GET https://api.anthropic.com/v1/models * * @param string $api_key * @return array {success:bool, models:array, error:string} */ private static function fetch_claude_models($api_key) { $models = array(); $after_id = ''; $timeout = defined('AAGL_TIMEOUT_SEARCH_API') ? AAGL_TIMEOUT_SEARCH_API : 30; // ページネーション(after_id 方式)。暴走防止に最大5ページで打ち切る for ($page = 0; $page < 5; $page++) { $url = 'https://api.anthropic.com/v1/models?limit=100'; if ('' !== $after_id) { $url .= '&after_id=' . rawurlencode($after_id); } $response = wp_remote_get($url, array( 'headers' => array( 'x-api-key' => $api_key, 'anthropic-version' => '2023-06-01', ), 'timeout' => $timeout, )); if (is_wp_error($response)) { return array('success' => false, 'models' => array(), 'error' => '通信エラー: ' . $response->get_error_message()); } $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if (200 !== $code) { $message = isset($body['error']['message']) ? $body['error']['message'] : ('HTTP ' . $code); return array('success' => false, 'models' => array(), 'error' => sanitize_text_field($message)); } if (!isset($body['data']) || !is_array($body['data'])) { return array('success' => false, 'models' => array(), 'error' => 'レスポンス形式が想定と異なります'); } foreach ($body['data'] as $item) { if (empty($item['id'])) { continue; } $id = sanitize_text_field($item['id']); $models[$id] = sanitize_text_field($item['display_name'] ?? $id); } if (empty($body['has_more']) || empty($body['last_id'])) { break; } $after_id = $body['last_id']; } return array('success' => true, 'models' => $models, 'error' => ''); } /** * Gemini: GET https://generativelanguage.googleapis.com/v1beta/models * * @param string $api_key * @return array {success:bool, models:array, error:string} */ private static function fetch_gemini_models($api_key) { $models = array(); $page_token = ''; $timeout = defined('AAGL_TIMEOUT_SEARCH_API') ? AAGL_TIMEOUT_SEARCH_API : 30; for ($page = 0; $page < 5; $page++) { $url = 'https://generativelanguage.googleapis.com/v1beta/models?pageSize=200'; if ('' !== $page_token) { $url .= '&pageToken=' . rawurlencode($page_token); } $response = wp_remote_get($url, array( 'headers' => array('x-goog-api-key' => $api_key), 'timeout' => $timeout, )); if (is_wp_error($response)) { return array('success' => false, 'models' => array(), 'error' => '通信エラー: ' . $response->get_error_message()); } $code = wp_remote_retrieve_response_code($response); $body = json_decode(wp_remote_retrieve_body($response), true); if (200 !== $code) { $message = isset($body['error']['message']) ? $body['error']['message'] : ('HTTP ' . $code); return array('success' => false, 'models' => array(), 'error' => sanitize_text_field($message)); } if (!isset($body['models']) || !is_array($body['models'])) { return array('success' => false, 'models' => array(), 'error' => 'レスポンス形式が想定と異なります'); } foreach ($body['models'] as $item) { if (empty($item['name'])) { continue; } // 記事本文の生成に使えるモデルだけを対象にする $methods = isset($item['supportedGenerationMethods']) && is_array($item['supportedGenerationMethods']) ? $item['supportedGenerationMethods'] : array(); if (!in_array('generateContent', $methods, true)) { continue; } // "models/gemini-2.5-flash" → "gemini-2.5-flash" $id = sanitize_text_field(preg_replace('#^models/#', '', $item['name'])); if ('' === $id || self::is_non_text_model($id)) { continue; } $models[$id] = sanitize_text_field($item['displayName'] ?? $id); } if (empty($body['nextPageToken'])) { break; } $page_token = $body['nextPageToken']; } return array('success' => true, 'models' => $models, 'error' => ''); } /** * 記事本文生成に使わないモデルを除外する * * 画像・音声・埋め込み系はプルダウンに並べても選ぶ理由がないため落とす。 * * @param string $model_id * @return bool 除外対象なら true */ private static function is_non_text_model($model_id) { $ng = array('embedding', 'aqa', 'tts', 'image', 'audio', 'veo', 'imagen', 'vision'); foreach ($ng as $needle) { if (false !== strpos($model_id, $needle)) { return true; } } return false; } // ========================================================================= // 週次チェック(廃止の事前検知) // ========================================================================= /** * 週次CRONの実処理: 一覧を更新し、設定中モデルが消えていれば通知する */ public static function run_scheduled_refresh() { foreach (self::PROVIDERS as $provider) { if (empty(AAGL_Utilities::get_api_key($provider . '_api_key'))) { continue; } $result = self::refresh($provider); if (!$result['success']) { continue; } // 手入力・プルダウンいずれの設定値も、消えていないか確認する $ai_settings = self::get_raw_ai_settings(); $configured = sanitize_text_field($ai_settings[$provider . '_model_custom'] ?? ''); if ('' === $configured) { $configured = sanitize_text_field($ai_settings[$provider . '_model'] ?? ''); } if ('' === $configured) { continue; } if (self::is_model_available($provider, $configured)) { self::clear_notified($provider); continue; } // 一覧から消えた = 提供終了した可能性が高い AAGL_Utilities::flag_model_error( $provider, $configured, 'モデル一覧に存在しません(提供終了の可能性)' ); self::notify_model_gone($provider, $configured); } } /** * 設定中モデルが一覧から消えたことを管理者にメール通知する(同一モデルにつき1回) * * @param string $provider * @param string $model */ private static function notify_model_gone($provider, $model) { $notified = get_option(self::OPTION_NOTIFIED, array()); if (!is_array($notified)) { $notified = array(); } if (($notified[$provider] ?? '') === $model) { return; // 通知済み } $site_name = get_bloginfo('name'); $subject = sprintf('[%s] AI Article Generator: 設定中のAIモデルが提供終了した可能性があります', $site_name); $body = sprintf( "サイト: %s\n設定中のAIモデル: %s(%s)\n\n" . "このモデルが提供元のモデル一覧から見つかりませんでした。提供が終了した可能性があります。\n" . "記事生成が停止する前に、管理画面の「API設定」からモデルを変更してください。\n\n" . "設定画面: %s\n", home_url(), $model, $provider, admin_url('admin.php?page=aagl-settings') ); wp_mail(get_option('admin_email'), $subject, $body); $notified[$provider] = $model; update_option(self::OPTION_NOTIFIED, $notified, false); AAGL_Utilities::log(sprintf('Model gone notification sent (%s): %s', $provider, $model), 'warning'); } /** * 通知済みフラグを解除 * * @param string $provider */ private static function clear_notified($provider) { $notified = get_option(self::OPTION_NOTIFIED, array()); if (!is_array($notified) || !isset($notified[$provider])) { return; } unset($notified[$provider]); if (empty($notified)) { delete_option(self::OPTION_NOTIFIED); } else { update_option(self::OPTION_NOTIFIED, $notified, false); } } // ========================================================================= // 自動切替の記録 // ========================================================================= /** * モデル/プロバイダを自動で切り替えて生成した事実を記録する * * 黙って切り替えると設定の誤りが放置されるため、管理画面に出して気づかせる。 * * @param string $type 'model'|'provider' * @param string $from 切替元 * @param string $to 切替先 */ public static function note_auto_switch($type, $from, $to) { $switches = get_option(self::OPTION_AUTOSWITCH, array()); if (!is_array($switches)) { $switches = array(); } $switches[$type] = array( 'from' => sanitize_text_field($from), 'to' => sanitize_text_field($to), 'time' => current_time('mysql'), ); update_option(self::OPTION_AUTOSWITCH, $switches, false); AAGL_Utilities::log(sprintf('Auto switch (%s): %s -> %s', $type, $from, $to), 'warning'); } /** * 自動切替の記録を取得 * * @return array */ public static function get_auto_switches() { $switches = get_option(self::OPTION_AUTOSWITCH, array()); return is_array($switches) ? $switches : array(); } /** * 自動切替の記録を消す(設定を保存し直したタイミングで呼ぶ) */ public static function clear_auto_switches() { delete_option(self::OPTION_AUTOSWITCH); } // ========================================================================= // AJAX // ========================================================================= /** * AJAX: 「モデル一覧を今すぐ更新」 */ public static function ajax_refresh_catalog() { if (!current_user_can('manage_options')) { wp_send_json_error(array('message' => '権限がありません。')); } check_ajax_referer('aagl_ajax_nonce', 'nonce'); $messages = array(); $any_ok = false; foreach (self::PROVIDERS as $provider) { if (empty(AAGL_Utilities::get_api_key($provider . '_api_key'))) { $messages[] = sprintf('%s: APIキー未設定のためスキップ', ucfirst($provider)); continue; } $result = self::refresh($provider); if ($result['success']) { $any_ok = true; $messages[] = sprintf('%s: %d件を取得', ucfirst($provider), $result['count']); } else { $messages[] = sprintf('%s: 取得失敗(%s)', ucfirst($provider), $result['error']); } } $payload = array('message' => implode(' / ', $messages)); if ($any_ok) { wp_send_json_success($payload); } wp_send_json_error($payload); } // ========================================================================= // 内部ヘルパー // ========================================================================= /** * ai_settings をそのまま取得する * * AAGL_Utilities::get_ai_settings() は既知キーだけを返すため、 * 手入力モデルIDなどの追加キーはここで直接読む。 * * @return array */ private static function get_raw_ai_settings() { $settings = get_option('aagl_settings', array()); $ai = isset($settings['ai_settings']) && is_array($settings['ai_settings']) ? $settings['ai_settings'] : array(); return $ai; } }