確認環境:WordPress 7.0 RC3で実装・検証し、5月21日の安定版でも同じ挙動を再確認しています。PHP 8.3.30、Rapls AI Chatbot 1.8.0。RC段階で実装したので、本文はWP AI Clientの仕様が今後動きうる前提で書いていますが、ここで述べる挙動(
with_history()の型、role enum、復号通知の経路)はRC3と安定版の双方で一致していました。検証日はRC3が5月18日、安定版が5月21日です。
WordPress 7.0で、AI Clientが標準に入りました。サイト管理者がConnectorsにプロバイダを繋いでおけば、プラグインは自前でAPIキーを抱えなくても、その先のモデルへ問い合わせられます。
このチャットボットは、もともとOpenAI、Claude、Gemini、OpenRouterを自分で抱えていました。各プロバイダのキーを暗号化して持ち、モデルを選ばせ、レスポンスを整える。その層を、全部自前で書いていたわけです。7.0のAI Clientは、その層をプラットフォーム側に肩代わりさせる申し出でした。WP AI Clientの全体像とこのプラグインでの対応方針は、前の記事で読み解いておきました。乗せ替えたい。キーもモデルも、プラグインの外、Connectorsに預けてしまいたい。
ただ、緊張が一つありました。ユーザーの大半は、まだ7.0未満にいます。新しいプロバイダを足すことはできても、古いWordPressで一行たりとも壊してはいけません。新機能は、存在しないAI Clientの上で、静かに眠っていなければならない。
乗せ替えそのものは、申し出に乗るだけのはずでした。検出して、繋いで、古い環境では眠らせる。難しい話ではない、と思っていました。
時間を溶かすことになったのは、その予想のどれでもない、たった一点でした。
会話の履歴を with_history() に渡す、ただそれだけのところ
それは、会話の履歴をwith_history()に渡す、ただそれだけのところでした。ここで二段に転びました。
一段目は、派手に転んだ方です。このプラグインは普段、会話履歴をChatML風の生配列で持っています。['role' => 'user', 'content' => '…']という素直な形です。これをそのまま渡しました。
|
1 2 3 4 5 6 7 8 9 10 11 |
// プラグイン内部の履歴表現(ChatML風の生配列) $history = [ ['role' => 'user', 'content' => '東京の天気は?'], ['role' => 'assistant', 'content' => '晴れです。'], ['role' => 'user', 'content' => 'では大阪は?'], ]; $response = wp_ai_client_prompt( 'では大阪は?' ) ->with_history( $history ) // ← 配列を「1個の引数」として渡している ->generate_text(); |
これは動きません。WP AI ClientのwithHistory()はwithHistory( Message ...$messages )という型付きの可変長引数で、Message型の列を期待しています。そこへ生の配列を1個渡すと、$messages[0]にarrayを束ねようとしてTypeErrorになります(array given, Message expected)。
ここで一瞬、WordPress側のラッパーが受け止めてくれるかと期待しましたが、受け止めませんでした。WP_AI_Client_Prompt_Builderの__callはcatch (Exception $e)しか拾いません。TypeErrorはError系でExceptionではないので、捕まらずそのまま上へ抜けて、派手に落ちます。これは、むしろ良い失敗でした。すぐ気づけるからです。
問題は二段目でした。こちらは静かに転びます。
派手に落ちた経験から、Message型を正しく組むコードを書きました。役割をuser/modelに畳み、テキストをMessagePartで包み、配列にしてUserMessage/ModelMessageに渡し、最後にwith_history()へスプレッドで展開します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\DTO\MessagePart; private function apply_history( array $history ): array { $messages = []; foreach ( $history as $turn ) { $text = (string) ( $turn['content'] ?? '' ); if ( '' === $text ) { continue; // 空ターンは渡さない } $part = new MessagePart( $text ); // 文字列1個でtextパート // user 以外は model に畳む(二値強制) $messages[] = ( ( $turn['role'] ?? '' ) === 'user' ) ? new UserMessage( [ $part ] ) : new ModelMessage( [ $part ] ); } return $messages; } |
|
1 2 3 4 5 |
$messages = $this->apply_history( $history ); $response = wp_ai_client_prompt( $latest_user_text ) ->with_history( ...$messages ) // スプレッド展開 ->generate_text(); |
見やすさのためにuseで書いていますが、本番のコードではuseではなく文字列クラス名とclass_exists()でMessagePartなどを参照しています(実際のガードはMessagePart / UserMessage / ModelMessageの3クラスすべての存在を確認しています)。WP 7.0未満のサイトでDTOクラスが存在しなくても、ファイル自体がfatalしないためです。このガードが、すぐ後で書く本当の罠に直結します。
役割の畳み方について、一つ正直に書いておきます。user以外を全部modelに倒しているのは、雑なのではなく、前提に寄りかかった割り切りです。WP AI Clientのrole enumはuserとmodelの二値しかなく、systemは別経路(using_system_instruction)へ行きます。しかもsystemメッセージは手前の処理で分離済みなので、apply_history()に届くのは実質userとassistant/botだけです。だから二値強制で破綻しません。ただしこれは「systemがここに来ることはない」という前提の上に乗っています。もしその前提を破る入力が将来来たら、systemが黙ってmodelターンに化けます。今は安全ですが、安全の理由が前提の側にある、というのは覚えておくべきことでした。
そして、本当の罠はこの先にありました。
正しくマーシャリングするコードは書けました。Message型を組み、配列で包み、スプレッドで展開する。一段目のTypeErrorはもう出ません。それで安心してしまったのが、二段目の入口でした。
本番のコードは、いま見せたマーシャリングをそのままtry/catchで包んでいます。理由は7.0未満との互換です。古いWordPressにはWP AI ClientのDTOクラスが存在しないので、組み立ての途中で何かが投げても、サイト全体をfatalさせたくない。だからclass_exists()でDTOの有無を確かめ、組み立て全体をtry/catch(\Throwable)で囲い、何か起きたら履歴を丸ごと諦めて先へ進む。そういう防御を入れました。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 実コードの構造(簡略化) if ( ! class_exists( $message_part_class ) ) { // …WP_DEBUG時のみログ… return; // 履歴を組まずに戻る } try { // …MessagePart / UserMessage / ModelMessage を組み立てて with_history() … } catch ( \Throwable $e ) { // …WP_DEBUG時のみログ… return; // 履歴を組まずに戻る } |
設計としては筋が通っています。古い環境で落ちるより、文脈を一回諦めてでも応答を返す方がいい。そう判断しました。問題は、この防御が「諦めた」ことを誰にも告げないことでした。
apply_history()はvoidで、スキップ経路に入るとwith_history()が一度も呼ばれないまま戻ります。ビルダーに残るのは、システム指示と、wp_ai_client_prompt()に渡した最新の一ターンだけ。generate_text()は何事もなく200を返します。会話は成立しているように見えます。ただ、二手前を覚えていない。エラーログもない。例外もない。レスポンスは正常。欠けているのは文脈だけです。

with_history() は呼ばれず、最新の一ターンだけで200が返る。失敗を知らせる信号は出ない。しかも、本番で実際に火を噴くのはclass_exists()の方ではありません。wp_ai_client_prompt()が存在する時点でAI Clientはロード済みなので、DTOも普通はautoloadできます。class_exists()がfalseになるのは、SDKが将来名前空間や構造を変えたときの、潜在的な罠です。現実に履歴を落とすのはtry/catch(\Throwable)の方です。SDK側のシグネチャがほんの少しズレる(UserMessageの引数形、with_historyの型、role enumの変更)と、正しく書いたはずのマーシャリングが投げ、ここで握られて、履歴が静かに消えます。
そして、沈黙の決定打はここにあります。どちらのスキップ経路も、ログはif ( defined('WP_DEBUG') && WP_DEBUG )の内側に書いてあります。本番のWP_DEBUGはoffです。つまり防御コードはちゃんとログを書いているのに、そのログ自体が本番では沈黙します。安全網が失敗を飲み込み、その失敗を知らせるはずの警報まで、フラグの裏で切れている。善意の防御が、二重に音を消していました。失敗は起きています。ただ、それを伝える信号が、どこにも出ない。
一段目と二段目の差は、結局「ガードの有無」そのものでした。ガードのない最小再現はTypeErrorで派手に落ちて、すぐ気づけます。ガードを付けた本番コードは、同じ失敗を静かに飲み込んで、文脈だけを抜いた応答を平然と返します。落ちてくれる方が、ずっと親切だったのです。
どうやってこの沈黙に気づいたのか、正確な経緯はもう手元の記録に残っていません。ただ、当時の検証メモには「マルチターンの会話で、過去の発言を踏まえた返答が返ってくること」が独立した確認項目として立っていました。ユニットテストでは出ず、手で何ターンか会話して初めて露見する種類の壊れ方なので、おそらくその手動の検証の途中で踏んだのだと思います。
キーを持たないプロバイダが、プラグインの暗黙の前提を踏み抜いた
with_history()の沈黙を抜けた先で待っていたのは、まったく別種の問題でした。型の話ではありません。プラグイン全体が、誰にも明文化されないまま握っていた前提を、WP AI Clientが踏み抜いたのです。その前提とは「どのプロバイダも、必ずプラグイン側にAPIキーを持っている」というものでした。
wpaiはキーをConnectorsに丸投げします。プラグインの中にwpaiのAPIキーは存在しません。たったそれだけのことが、想像していなかった場所から順に顔を出しました。浅いところから書きます。
いちばん浅い穴:設定スキーマ
最初は、新しいプロバイダをスキーマに教えるだけの作業でした。許可リストにwpaiを足さないと、in_arrayで弾かれて選択がリセットされます(無効な値は直前の保存値、無ければopenaiに差し替わります)。sanitizeにwpai_modelを通し、デフォルト値に空文字を置く。地味な、通し忘れると動かない類の対応です。
ただ、ここで一つ気づくことがあります。他のプロバイダはどれも_api_keyと_modelの両方を持つのに、wpaiは_modelしか持ちません。
|
1 2 3 4 |
openai : openai_api_key + openai_model openrouter : openrouter_api_key + openrouter_model wpai : (キー欄なし) + wpai_model |
wpai_api_keyという設定はどこにも存在しません。キーを持たないという事実が、設定スキーマに「穴」として可視化されています。「全プロバイダはキー欄を持つ」という前提の、いちばん静かな破れ目がここでした。このときはまだ、それが下の二つの予兆だとは思っていませんでした。
真ん中の段:REST事前チェック
チャットのREST事前チェックは、呼び出し前にアクティブなプロバイダのキーを復号し、空ならapi_key_missingで400を返して即弾く、という作りでした。「呼ぶ前にキーの存在を確かめる」。理にかなっています。キーが無いのにAIへ投げても無駄だからです。
wpaiはこの前提に当てはまりません。キーが無いのが正常なので、事前チェックを名前で丸ごと囲って除外しました。能力判定ではなく、!== 'wpai'という名前ベースのexemptです。
ここで起きたのは、単なる除外ではなく可用性チェックの移動でした。他のプロバイダは「呼ぶ前にキーで」可用性を確かめます。wpaiは「呼ぶ時にConnectorの可用性で」確かめる。is_supported_for_text_generation()に肩代わりさせました。同じ「使えるか」の判定が、キーの存在チェックから、呼び出し時の能力チェックへとずれたわけです。前提が一つ、例外を学んだ瞬間でした。
いちばん深い段:復号失敗通知
そして、この章の本題です。「APIキーの復号に失敗しました」という管理画面通知が、wpaiユーザーに誤発火していました。最初はwpai対応の漏れだと思いました。実際は違いました。wpaiは、もっと前から壊れていたものを炙り出しただけだったのです。
誤発火は三段の連鎖で起きていました。設定ページを開くたびに走る移行処理(maybe_migrate_legacy_keys())が、全プロバイダのキーを総なめします。そのループの中で、失敗時にグローバルなtransientを立てる復号ラッパーを呼んでいました。そして通知の表示判定は、そのtransientと権限だけを見て、無条件に表示していました。どのプロバイダが失敗したのか、それが今アクティブなプロバイダなのかを、一切見ずに。
結果はこうです。OpenAIに乗り換えたユーザーの環境に、塩を替えた後で復号できなくなった古いClaudeキーが残っている。設定ページを開く。移行処理がその死んだキーに触れ、グローバル警報が立つ。OpenAIで平和に動いているのに、捨てたはずのClaudeキーのせいで「復号に失敗しました」を食らう。警報は正しく鳴っています。ただ、鳴っている相手が間違っている。信号は出ているのに、それが指す真実がずれていました。

ここで腑に落ちたことがあります。この誤発火は、wpaiが持ち込んだバグではありません。複数プロバイダのキーが同時に保存される設計になった時点で、もう起きうるバグでした。アクティブは一つでも、どの古いキーでもグローバル警報を立てられたのですから。ただ、誰もその経路を踏まなかったから、誰も気づかなかった。キーを持たないwpaiを足して、「そのキーは誰のものか、それはアクティブか」を初めて真面目に問うたときに、ようやく露見したのです。
だから直し方も、wpaiの例外追加にはしませんでした。表示判定を、四段のゲートに一般化しました。
|
1 2 3 4 5 6 7 |
// 通知の表示判定:「アクティブなプロバイダ自身のキーが、本当に問題か?」 if (active === 'wpai') return; // ① キーレス=プラグイン側キーは無関係 if (active の *_api_key が空) return; // ② そもそも未設定 if (active 自身のキーが正常に復号できる) return; // ③ 失敗は別の未使用プロバイダ由来 // ④ ここまで来た時だけ=アクティブ自身のキーが本当に壊れている show_notice(); |
=== 'wpai'は、例外として付け足したものではなく、「アクティブなプロバイダ自身のキーが、本当に問題なのか?」という問いの、第一分岐にすぎません。並びそのものが、それを示しています。
この章の持ち帰りは、たぶんこれです。新しい抽象を足すと、新しい例外がN個要るだけではありません。「その不変条件は、実は最初から成り立っていなかった」ことを炙り出すことがあります。wpaiは、私が暗黙に信じていた二つの前提——「全プロバイダはキーを持つ」「復号失敗=唯一のキーが壊れている」——のうち、前者を破り、後者がとっくに偽だったことを教えました。
「全プロバイダはAPIキーを持つ」という前提は、最初から偽だったのです。
temperature の400を、一度だけ握ってリトライする
ここまでが重い話でした。最後は、もっと素朴な一手を記録しておきます。
Connectorsの先がGPT-5やo系のモデルだと、カスタムなtemperatureを受け付けず、400を返してきます。これらのモデルは固定の温度しか持たないので、こちらが指定した値が拒否されます。既知の挙動です。
対処は泥臭いものです。一度目の生成がエラーを返し、そのエラー本文にtemperatureという語が含まれていて、かつ自分が実際にカスタム温度を指定していたとき。この三つが揃ったときだけ、temperatureを外してもう一度だけ投げます。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// 1回目:temperature を付けて生成 $text = $this->build_prompt($prompt, $system, $history, $options, false)->generate_text(); // エラー本文に "temperature" を含み、かつ自分が温度を指定していた時だけ、一度だけ外して再送 if (is_wp_error($text) && stripos($text->get_error_message(), 'temperature') !== false && isset($options['temperature']) ) { $text = $this->build_prompt($prompt, $system, $history, $options, true)->generate_text(); } // ループも再帰もフラグも無い。だから「一度だけ」は構造が勝手に保証する。 |
この章の素朴さは、二か所にあります。
ひとつは、エラーの判定が構造化されたコードではなく、人間可読のメッセージ本文への文字列一致だということです。striposでtemperatureという語を引っかけているだけです。エラーコードのような安定した契約ではなく、メッセージの文言に賭けています。三つ目のissetガードは効いていて、温度を指定してもいないのにtemperatureエラーが来ても、無駄な再送はしません。
もうひとつは、「一度だけ」を見張る仕組みが要らないことです。ループも再帰もリトライフラグもありません。再送はifブロック一個きりで、外す方はbuild_prompt()に$skip_temperature = trueを渡してusing_temperature()の呼び出しごと省きます。1.0などに戻すのではなく、パラメータを完全に省いてモデルの既定に委ねる。直線のコードだから、束ねるものがないわけです。
ただし、正直に書いておきます。この判定はエラー本文の文字列マッチなので、SDK側が文言を変えたり多言語化したりしたら、黙って効かなくなります。今日は届いている信号が、ある日、誰にも告げず消える。既知で十分に安定したクセに賭けた、承知の上の割り切りです。
次にやるときの自分へ
三つの章は、別々の話です。型の話、不変条件の話、文字列マッチの話。だが読み返すと、同じ構造が通底しています。どれも、壊れたこと自体は起きている。ただ、それを知らせる信号が、真実とずれている。
with_history()に生配列を渡せば、派手に落ちます。だが互換のための防御で包んだ瞬間、失敗は履歴を黙って捨てる側に回り、それを知らせるログはWP_DEBUGの裏で誰にも届きません。信号が、無い。復号失敗の通知は逆でした。警報はちゃんと鳴ります。ただ、いま使ってもいない古いキーを指して鳴る。信号は、間違った真実を指す。temperatureの文字列マッチは、今日は効いていて、SDKが文言を変えた日に、誰にも告げず効かなくなる。信号が、いつか消える。
どれも、壊れたことが正しく伝わらない。

この記事を書くために、当時どうやってこれに気づいたのかを探しました。コードを読み返し、コミットログを辿り、当時のAIとの作業の記録や、自分で残した検証メモまで見ました。出てきませんでした。気づきの瞬間は、どこにも記録されていない。残っていたのは、防御コメントの書き方と、try/catchの入り方。コードに刻んだ痕跡だけでした。
過去の自分の判断を未来の自分に伝えたのは、記憶ではありませんでした。コードでした。
だとすれば、今日書く防御コードとコメントは、いつか全部忘れた自分が読む申し送りです。決意ではありません。事実として、そうなります。だから——
安全網には、鳴る警報を付けておく。ログをWP_DEBUGの裏に隠さない。既定値に逃げるとき(履歴を捨てる、roleを畳む、文字列で殴る)、逃げたことが聞こえるように印を残す。静かに壊れるものは、静かに直す機会も奪うからです。
次にここを開く自分は、たぶん何も覚えていません。覚えているのは、コードだけです。


コメント