[機能]AIエージェントと連携

できること

・お客様からメッセージに対して、AIエージェントから自動応答する。
・お客様から質問に対して、AIエージェントから回答生成する。
・会話メッセージの要約およびTODOの作成する。
・定期的に会話メッセージを分析し、顧客満足度や顧客ランク評価など任意情報抽出する

Agentforce・ChatGPT・Gemini・Claude・自社カスタムAIを接続可能です。

設定方法

準備 必要なプロンプトを事前に登録し、以下各機能にて選択する。※利用箇所以下②以後にて参照する。
準備② リモートサイトの設定 必要なドメイン許可
  Claude : https://api.anthropic.com
  OpenAI : https://api.openai.com

DX-LINE→ホーム→設定・登録→すべて表示→プロンプト
選択する。

準備 ② AIエージェントの接続情報をDX-LINEへ設定する

① DX-LINE設定にて、「生成AI利用」有効化し、「生成AI関連設定」タブへ遷移する

➁ 有効化後に、「認証情報」
Edit押下し、接続入力を入力する。

※ChatGPTキーの取得方法はこちらへ参照する

③要約生成機能有効化
有効することによって、チャット画面の要約生成ボタン利用可能になる。
登録済みのプロンプト選択して、TODO自動作成有効の場合、TODOのデフォルト件名、期日、紐付け先を指定する

④定期的にチャットメッセージから関心情報を分析
「定期処理有効」選択し、対象メッセージと友だち、実施頻度指定し、抽出した情報を友だち項目へ更新する。

活用① AIエージェントから自動応答する

以下の自動応答登録すれだけで、AIによる自動応答できる。

マッチタイプ:
  AIボット
プロンプト:
  作成済みプロンプト
・有効:選択

活用② AIエージェントから会話内容による回答を生成する

チャット画面のアインシュタインアインコをクリックし、AIアシスタント画面開く

ユーザの内容をここで編集しても利用可能です。

生成内容をダブルクリックしたらそのまま編集可能です。

活用③ AIエージェントでチャットの内容を要約生成し、TODOを自動作成も可能です。

Claude (Anthropic API) 連携拡張メソッド参考例:

/**
 * Claude (Anthropic API) 実装クラス ── DX-LINE 用
 *
 * - FmlExternalIF インタフェースを実装
 * - Anthropic Messages API ( https://api.anthropic.com/v1/messages ) を呼び出す
 * - OpenAI (ChatGPT) 版との主な差異:
 *     1. 認証ヘッダーが「x-api-key」(Bearer ではない)
 *     2. 「anthropic-version」ヘッダーが必須
 *     3. system プロンプトは messages 配列の外(トップレベル「system」)に置く
 *     4. max_tokens が必須項目
 *     5. レスポンス構造が異なる ( content[0].text / usage.input_tokens + output_tokens )
 *     6. frequency_penalty / presence_penalty は未サポートのため送信しない
 *
 * ★ global: global interface FmlExternalIF を実装するクラスは global にする必要がある
 *           callAIApi メソッドも global で宣言する
 * ★ bfml__FmlChatGPTLog__c にログを生成するには、環境によって、ゲストユーザに、項目の編集権限の付与が必要
 */
global with sharing class FmlAIClaude extends bfml.FmlExternalIF {

    //'https://api.anthropic.com/v1/messages';
    private static final String API_URL = 'https://api.anthropic.com/v1/messages';

    //'sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxx';
    private static final String API_KEY =  'sk-XXXXXXXXX';

    //'claude-opus-4-7' / 'claude-sonnet-4-6' など
    private static final String API_MODEL = 'claude-opus-4-7';

    //Anthropic API バージョン (固定値)
    private static final String ANTHROPIC_VERSION = '2023-06-01';

    //Claude では max_tokens が必須項目のためデフォルト値を用意
    private static final Integer DEFAULT_MAX_TOKENS = 1024;

    /**
     * Anthropic Messages API を呼び出す
     * @param  bean リクエスト情報
     * @return      AI応答文字列
     */
    override global String callAIApi(bfml.FmlExternalIF.IntentBean bean) {

        if(null == bean) {
            return 'Error:IntentBean null ERROR';
        }

        if(String.isBlank(bean.model)) {
            bean.model = API_MODEL;
        }

        // HTTPリクエストの作成
        HttpRequest req = new HttpRequest();
        req.setEndpoint(FmlAIClaude.API_URL);
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');
        req.setTimeout(50000);
        // Anthropic 認証ヘッダー (Bearer ではない)
        req.setHeader('x-api-key', FmlAIClaude.API_KEY); 
    //以下のように指定ログイン情報方式に変更をおすすめ
    //例: req.setHeader('x-api-key', '{!$Credential.anthropic.apikey}');
        req.setHeader('anthropic-version', FmlAIClaude.ANTHROPIC_VERSION);

        // ──────────────────────────────────────────────
        // Claude では system プロンプトを messages 配列内ではなく
        // トップレベルの「system」フィールドに置く必要がある。
        // OpenAI 形式の messages から system ロールを抽出して分離する。
        // ──────────────────────────────────────────────
        String systemPrompt = '';
         
        List<Map<String, Object>> userMessages = new List<Map<String, Object>>();

        if(bean.messages != null) {
            for(bfml.FmlExternalIF.MessageBean msg : bean.messages) {
                if(msg == null || String.isBlank(msg.role)) continue;
        
                if('system'.equalsIgnoreCase(msg.role)) {
                    String sysText = (msg.content instanceof String)
                                     ? (String)msg.content
                                     : JSON.serialize(msg.content);
                    systemPrompt += (String.isBlank(systemPrompt) ? '' : '\n') + sysText;
                } else if('user'.equalsIgnoreCase(msg.role)) {
                    // ★ MessageBean を Map に詰め替える
                    userMessages.add(new Map<String, Object>{
                        'role'    => msg.role,
                        'content' => msg.content
                    });
                }
            }
        }
            
        // max_tokens は必須。未指定 or 0 以下ならデフォルト値を使う
        Integer maxTokens = (bean.max_tokens != null && bean.max_tokens > 0)
                            ? (Integer)bean.max_tokens
                            : DEFAULT_MAX_TOKENS;

        // リクエストボディの設定
        // Claude は frequency_penalty / presence_penalty を受け付けないため送らない
        Map<String, Object> body = new Map<String, Object>{
            'model'      => bean.model,
            'stream'     => bean.stream,
            'messages'   => userMessages,
            'max_tokens' => maxTokens
        };
            
        if(String.isNotBlank(systemPrompt)) {
            body.put('system', systemPrompt);
        }

        //オプション追加セットする (parameters で渡された値が優先される)
        try {
            if(String.isNotBlank(bean.parameters)) {
                Map<String, Object> parameters =
                    (Map<String, Object>)JSON.deserializeUntyped(bean.parameters);
                body.putAll(parameters);
            }
        } catch(Exception e) {
            System.debug('parameters parse error');
        }
        
        String requestBody = JSON.serialize(body); // suppressApexObjectNulls = true]
                
        req.setBody(requestBody);

        // HTTPリクエストの送信
        Http http = new Http();
        HttpResponse res = http.send(req);

        // 応答の処理
        if (res.getStatusCode() == 200) {
            Map<String, Object> resBody =
                (Map<String, Object>) JSON.deserializeUntyped(res.getBody());

            if (resBody.containsKey('content')) {
                List<Object> contents = (List<Object>)resBody.get('content');

                // content は複数ブロック返ることがある (text, tool_use 等)
                // type=text のブロックを連結して回答とする
                String retMsg = '';
                for(Object c : contents) {
                    Map<String, Object> block = (Map<String, Object>)c;
                    String type = (String)block.get('type');
                    if('text'.equalsIgnoreCase(type)) {
                        retMsg += (String)block.get('text');
                    }
                }

                //Iuserkey は友だちの場合のみ保持する (ChatGPT 実装と同じロジック)
                try {
                    String objName = Id.valueOf(bean.userkey).getsobjecttype()
                                       .getDescribe().getName();
                    //友だち詳細ページの場合
                    if(!bfml__FmlLineMember__c.getSObjectType().getDescribe().getName().equals(objName)) {
                        bean.userkey = null;
                    }
                } catch(Exception e) {
                    bean.userkey = null;
                    System.debug(e.getMessage());
                }

                try {
                    // usage の合計トークン数を計算
                    // Claude は input_tokens / output_tokens を別々に返す
                    Decimal totalTokens = 0;
                    if(resBody.containsKey('usage')) {
                        Map<String, Object> usage = (Map<String, Object>)resBody.get('usage');
                        Decimal inTok  = (usage.get('input_tokens')  != null)
                                         ? (Decimal)usage.get('input_tokens')  : 0;
                        Decimal outTok = (usage.get('output_tokens') != null)
                                         ? (Decimal)usage.get('output_tokens') : 0;
                        totalTokens = inTok + outTok;
                    }

                    //ログ作成 (ChatGPT 用ログオブジェクトを流用)
                    bfml__FmlChatGPTLog__c log = new bfml__FmlChatGPTLog__c(
                        bfml__Model__c        = bean.model,
                        bfml__Request__c      = requestBody,
                        bfml__Response__c     = res.getBody().toString(),
                        bfml__Total_tokens__c = totalTokens,
                        bfml__LineMemberID__c = bean.userkey
                    );
                    if (Schema.sObjectType.bfml__FmlChatGPTLog__c.isCreateable()) {
                        insert log;//ok
                    }
                } catch(Exception e) {
                    system.debug(e.getMessage());
                }

                if(String.isBlank(retMsg)) {
                    // stop_reason が max_tokens の場合は枠が足りていない可能性が高い
                    String stopReason = (String)resBody.get('stop_reason');
                    if('max_tokens'.equalsIgnoreCase(stopReason)) {
                        return 'Error:プロンプトのパラメータ:「max_tokens」を拡大してみてください。';
                    }
                    return 'Error:応答が空でした。';
                } else {
                    return retMsg;
                }
            }
        } else {
            // エラー応答 ( {"type":"error","error":{"type":"...","message":"..."}} )
            try {
                Map<String, Object> resBody =
                    (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
                Map<String, Object> errorObj = (Map<String, Object>) resBody.get('error');
                String errorMessage = (String) errorObj.get('message');
                return 'Error:' + errorMessage;
            } catch(Exception e) {
                return 'Error:HTTP ' + res.getStatusCode() + ' ' + res.getBody();
            }
        }

        return 'Error: System Error Try Again';
    }
}


//テストクラス
/**
 * FmlAIClaude のテストクラス
 *
 * - Anthropic Messages API 呼び出しを HttpCalloutMock で擬似化し、callAIApi の各分岐を検証する
 * - FmlAIClaude と同じく bfml 名前空間プレフィックス付きで参照し、サブスクライバ組織でも
 *   そのままデプロイできるようにしている
 * - 検証ポイント (OpenAI 実装との差異):
 *     1. system プロンプトが messages 配列の外(トップレベル system)へ分離されること
 *     2. frequency_penalty / presence_penalty がリクエストボディに含まれないこと
 *     3. max_tokens が必須として常にセットされること(未指定時はデフォルト 1024)
 *     4. content[] の type=text ブロックのみ連結されること
 *     5. usage.input_tokens + output_tokens でトークン数が算出されること
 */
@IsTest
private class FmlAIClaudeTest {

    // 期待されるデフォルト値 (FmlAIClaude の private 定数と一致させる)
    private static final String DEFAULT_MODEL = 'claude-opus-4-7';
    private static final Integer DEFAULT_MAX_TOKENS = 1024;

    // =========================================================
    // 設定可能な HTTP コールアウトモック
    //   - ステータスコード / レスポンスボディを指定して任意の応答を返す
    //   - 送信されたリクエストボディ・主要ヘッダーを記録し、検証に使う
    // =========================================================
    private class ClaudeMock implements HttpCalloutMock {
        private Integer statusCode;
        private String responseBody;

        public String  capturedBody;          // 送信ボディ(JSON)
        public String  capturedVersionHeader; // anthropic-version
        public String  capturedApiKeyHeader;  // x-api-key
        public String  capturedContentType;   // Content-Type

        public ClaudeMock(Integer statusCode, String responseBody) {
            this.statusCode   = statusCode;
            this.responseBody = responseBody;
        }

        public HttpResponse respond(HttpRequest req) {
            this.capturedBody          = req.getBody();
            this.capturedVersionHeader = req.getHeader('anthropic-version');
            this.capturedApiKeyHeader  = req.getHeader('x-api-key');
            this.capturedContentType   = req.getHeader('Content-Type');

            HttpResponse res = new HttpResponse();
            res.setStatusCode(this.statusCode);
            res.setBody(this.responseBody);
            res.setHeader('Content-Type', 'application/json');
            return res;
        }
    }

    // =========================================================
    // テストデータ生成ヘルパー
    // =========================================================

    /** system + user の標準メッセージを持つ IntentBean (model / max_tokens は未指定=デフォルト) */
    private static bfml.FmlExternalIF.IntentBean baseBean() {
        bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
        bean.stream = false;
        bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
            new bfml.FmlExternalIF.MessageBean('system', 'あなたは親切なアシスタントです。'),
            new bfml.FmlExternalIF.MessageBean('user', 'こんにちは')
        };
        return bean;
    }

    /** 正常レスポンス (text ブロック1つ・usage なし → ログは 0 トークンで作成される) */
    private static String bodyText(String text) {
        return JSON.serialize(new Map<String, Object>{
            'id'          => 'msg_001',
            'type'        => 'message',
            'role'        => 'assistant',
            'content'     => new List<Object>{
                new Map<String, Object>{ 'type' => 'text', 'text' => text }
            },
            'stop_reason' => 'end_turn'
        });
    }

    /** 正常レスポンス (text ブロック・usage あり) */
    private static String bodyTextWithUsage(String text) {
        return JSON.serialize(new Map<String, Object>{
            'content'     => new List<Object>{
                new Map<String, Object>{ 'type' => 'text', 'text' => text }
            },
            'usage'       => new Map<String, Object>{
                'input_tokens'  => 10,
                'output_tokens' => 20
            },
            'stop_reason' => 'end_turn'
        });
    }

    /** text 以外を含む複数ブロック (text ブロックのみ連結されることの検証用) */
    private static String bodyMultiBlock() {
        return JSON.serialize(new Map<String, Object>{
            'content'     => new List<Object>{
                new Map<String, Object>{ 'type' => 'text',     'text' => '前半' },
                new Map<String, Object>{ 'type' => 'tool_use', 'id'   => 't1', 'name' => 'calc' },
                new Map<String, Object>{ 'type' => 'text',     'text' => '後半' }
            },
            'stop_reason' => 'end_turn'
        });
    }

    /** text ブロックが無い (retMsg が空になる) レスポンス */
    private static String bodyEmptyText(String stopReason) {
        return JSON.serialize(new Map<String, Object>{
            'content'     => new List<Object>{
                new Map<String, Object>{ 'type' => 'tool_use', 'id' => 't1' }
            },
            'stop_reason' => stopReason
        });
    }

    /** content キーを持たないレスポンス */
    private static String bodyNoContent() {
        return JSON.serialize(new Map<String, Object>{
            'id'   => 'msg_x',
            'type' => 'message'
        });
    }

    /** Anthropic エラー形式のレスポンス */
    private static String bodyError(String message) {
        return JSON.serialize(new Map<String, Object>{
            'type'  => 'error',
            'error' => new Map<String, Object>{
                'type'    => 'authentication_error',
                'message' => message
            }
        });
    }

    /** 送信されたリクエストボディを Map に復元する */
    private static Map<String, Object> parseSent(ClaudeMock mock) {
        return (Map<String, Object>) JSON.deserializeUntyped(mock.capturedBody);
    }

    // =========================================================
    // 異常系: bean が null
    // =========================================================
    @IsTest
    static void testBeanNull() {
        Test.startTest();
        String result = new FmlAIClaude().callAIApi(null);
        Test.stopTest();

        System.assertEquals('Error:IntentBean null ERROR', result, 'null bean は専用エラーを返す');
    }

    // =========================================================
    // 正常系: デフォルトモデル / リクエスト整形 / ヘッダー
    //   - model 未指定 → デフォルトモデル
    //   - max_tokens 未指定 → デフォルト 1024
    //   - system がトップレベルへ分離
    //   - messages は user のみ
    //   - frequency_penalty / presence_penalty を送らない
    // =========================================================
    @IsTest
    static void testSuccess_DefaultModel_RequestShape() {
        ClaudeMock mock = new ClaudeMock(200, bodyText('はい、こんにちは。'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = baseBean(); // model / max_tokens 未指定

        Test.startTest();
        String result = new FmlAIClaude().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('はい、こんにちは。', result, 'text ブロックの内容を返す');

        // ── リクエストボディ検証 ──
        Map<String, Object> sent = parseSent(mock);
        System.assertEquals(DEFAULT_MODEL, (String) sent.get('model'), 'model 未指定時はデフォルトモデル');
        System.assertEquals(DEFAULT_MAX_TOKENS, (Integer) sent.get('max_tokens'), 'max_tokens 未指定時はデフォルト 1024');
        System.assertEquals('あなたは親切なアシスタントです。', (String) sent.get('system'), 'system はトップレベルへ分離される');
        System.assertEquals(false, sent.containsKey('frequency_penalty'), 'frequency_penalty は送らない');
        System.assertEquals(false, sent.containsKey('presence_penalty'), 'presence_penalty は送らない');

        List<Object> sentMsgs = (List<Object>) sent.get('messages');
        System.assertEquals(1, sentMsgs.size(), 'messages は user のみ');
        System.assertEquals('user', (String) ((Map<String, Object>) sentMsgs[0]).get('role'), 'role は user');

        // ── ヘッダー検証 ──
        System.assertEquals('2023-06-01', mock.capturedVersionHeader, 'anthropic-version 必須ヘッダー');
        System.assertEquals('application/json', mock.capturedContentType, 'Content-Type ヘッダー');
        System.assertNotEquals(null, mock.capturedApiKeyHeader, 'x-api-key ヘッダーがセットされる');
        System.assert(String.isNotBlank(mock.capturedApiKeyHeader), 'x-api-key ヘッダーは空でない');
    }

    // =========================================================
    // 正常系: model / max_tokens を明示指定
    // =========================================================
    @IsTest
    static void testSuccess_ProvidedModelAndMaxTokens() {
        ClaudeMock mock = new ClaudeMock(200, bodyText('応答'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        bean.model = 'claude-sonnet-4-6';
        bean.max_tokens = 2048;

        Test.startTest();
        String result = new FmlAIClaude().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('応答', result);

        Map<String, Object> sent = parseSent(mock);
        System.assertEquals('claude-sonnet-4-6', (String) sent.get('model'), '指定モデルが使われる');
        System.assertEquals(2048, (Integer) sent.get('max_tokens'), '指定 max_tokens が使われる');
    }

    // =========================================================
    // 正常系: max_tokens = 0 はデフォルトへフォールバック
    // =========================================================
    @IsTest
    static void testMaxTokensZero_UsesDefault() {
        ClaudeMock mock = new ClaudeMock(200, bodyText('ok'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        bean.max_tokens = 0; // 0 以下はデフォルト扱い

        Test.startTest();
        new FmlAIClaude().callAIApi(bean);
        Test.stopTest();

        Map<String, Object> sent = parseSent(mock);
        System.assertEquals(DEFAULT_MAX_TOKENS, (Integer) sent.get('max_tokens'), '0 指定時はデフォルト 1024');
    }

    // =========================================================
    // 正常系: 複数ブロックのうち type=text のみ連結
    // =========================================================
    @IsTest
    static void testMultipleTextBlocks_Concatenated() {
        Test.setMock(HttpCalloutMock.class, new ClaudeMock(200, bodyMultiBlock()));

        Test.startTest();
        String result = new FmlAIClaude().callAIApi(baseBean());
        Test.stopTest();

        System.assertEquals('前半後半', result, 'tool_use を除き text ブロックのみ連結される');
    }

    // =========================================================
    // 正常系: system メッセージが複数 → 改行で連結
    // =========================================================
    @IsTest
    static void testMultipleSystemMessages_Joined() {
        ClaudeMock mock = new ClaudeMock(200, bodyText('ok'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
        bean.stream = false;
        bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
            new bfml.FmlExternalIF.MessageBean('system', 'sys1'),
            new bfml.FmlExternalIF.MessageBean('system', 'sys2'),
            new bfml.FmlExternalIF.MessageBean('user', 'hi')
        };

        Test.startTest();
        new FmlAIClaude().callAIApi(bean);
        Test.stopTest();

        Map<String, Object> sent = parseSent(mock);
        System.assertEquals('sys1\nsys2', (String) sent.get('system'), '複数 system は改行連結される');
    }

    // =========================================================
    // 正常系: system の content が String 以外 → JSON 直列化される
    // =========================================================
    @IsTest
    static void testSystemContentNonString_Serialized() {
        ClaudeMock mock = new ClaudeMock(200, bodyText('ok'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
        bean.stream = false;
        bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
            new bfml.FmlExternalIF.MessageBean('system', new Map<String, Object>{ 'k' => 'v' }),
            new bfml.FmlExternalIF.MessageBean('user', 'hi')
        };

        Test.startTest();
        new FmlAIClaude().callAIApi(bean);
        Test.stopTest();

        Map<String, Object> sent = parseSent(mock);
        System.assertEquals('{"k":"v"}', (String) sent.get('system'), 'String 以外の content は JSON 直列化される');
    }

    // =========================================================
    // 正常系: messages が null でも例外なく呼び出せる
    // =========================================================
    @IsTest
    static void testMessagesNull() {
        ClaudeMock mock = new ClaudeMock(200, bodyText('応答'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
        bean.stream = false;
        bean.messages = null;

        Test.startTest();
        String result = new FmlAIClaude().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('応答', result);

        Map<String, Object> sent = parseSent(mock);
        System.assertEquals(false, sent.containsKey('system'), 'system は付与されない');
        System.assertEquals(0, ((List<Object>) sent.get('messages')).size(), 'messages は空');
    }

    // =========================================================
    // 正常系: null 要素 / role 空白 / assistant は除外される
    // =========================================================
    @IsTest
    static void testNullAndBlankRoleEntriesSkipped() {
        ClaudeMock mock = new ClaudeMock(200, bodyText('ok'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
        bean.stream = false;
        bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
            null,                                                       // null 要素 → skip
            new bfml.FmlExternalIF.MessageBean('', 'blank role'),       // role 空白 → skip
            new bfml.FmlExternalIF.MessageBean('assistant', '無視'),    // assistant → 対象外
            new bfml.FmlExternalIF.MessageBean('user', 'hi')           // user のみ採用
        };

        Test.startTest();
        new FmlAIClaude().callAIApi(bean);
        Test.stopTest();

        Map<String, Object> sent = parseSent(mock);
        List<Object> sentMsgs = (List<Object>) sent.get('messages');
        System.assertEquals(1, sentMsgs.size(), 'user メッセージのみが残る');
        System.assertEquals('user', (String) ((Map<String, Object>) sentMsgs[0]).get('role'));
    }

    // =========================================================
    // 正常系: parameters が body へマージ(上書き)される
    // =========================================================
    @IsTest
    static void testParametersOverride() {
        ClaudeMock mock = new ClaudeMock(200, bodyText('ok'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        bean.max_tokens = 100;
        bean.parameters = '{"temperature":0.3,"max_tokens":2000}';

        Test.startTest();
        new FmlAIClaude().callAIApi(bean);
        Test.stopTest();

        Map<String, Object> sent = parseSent(mock);
        System.assertEquals(true, sent.containsKey('temperature'), 'parameters の値が追加される');
        System.assertEquals(2000, (Integer) sent.get('max_tokens'), 'parameters の値が既存値を上書きする');
    }

    // =========================================================
    // 正常系: parameters が不正 JSON でも握りつぶして継続
    // =========================================================
    @IsTest
    static void testParametersInvalidJson() {
        Test.setMock(HttpCalloutMock.class, new ClaudeMock(200, bodyText('応答')));

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        bean.parameters = '{invalid json';

        Test.startTest();
        String result = new FmlAIClaude().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('応答', result, '不正 parameters は無視され処理継続');
    }

    // =========================================================
    // 異常系: 応答が空 + stop_reason=max_tokens
    // =========================================================
    @IsTest
    static void testEmptyText_StopMaxTokens() {
        Test.setMock(HttpCalloutMock.class, new ClaudeMock(200, bodyEmptyText('max_tokens')));

        Test.startTest();
        String result = new FmlAIClaude().callAIApi(baseBean());
        Test.stopTest();

        System.assertEquals('Error:プロンプトのパラメータ:「max_tokens」を拡大してみてください。', result);
    }

    // =========================================================
    // 異常系: 応答が空 + stop_reason がそれ以外
    // =========================================================
    @IsTest
    static void testEmptyText_StopOther() {
        Test.setMock(HttpCalloutMock.class, new ClaudeMock(200, bodyEmptyText('end_turn')));

        Test.startTest();
        String result = new FmlAIClaude().callAIApi(baseBean());
        Test.stopTest();

        System.assertEquals('Error:応答が空でした。', result);
    }

    // =========================================================
    // 異常系: 200 だが content キーが無い
    // =========================================================
    @IsTest
    static void testNoContentKey() {
        Test.setMock(HttpCalloutMock.class, new ClaudeMock(200, bodyNoContent()));

        Test.startTest();
        String result = new FmlAIClaude().callAIApi(baseBean());
        Test.stopTest();

        System.assertEquals('Error: System Error Try Again', result);
    }

    // =========================================================
    // 異常系: 非 200 + error.message を解析
    // =========================================================
    @IsTest
    static void testErrorResponse_WithMessage() {
        Test.setMock(HttpCalloutMock.class, new ClaudeMock(401, bodyError('invalid x-api-key')));

        Test.startTest();
        String result = new FmlAIClaude().callAIApi(baseBean());
        Test.stopTest();

        System.assertEquals('Error:invalid x-api-key', result, 'error.message を返す');
    }

    // =========================================================
    // 異常系: 非 200 + JSON 解析不能 → フォールバック
    // =========================================================
    @IsTest
    static void testErrorResponse_Fallback() {
        Test.setMock(HttpCalloutMock.class, new ClaudeMock(500, 'Internal Server Error'));

        Test.startTest();
        String result = new FmlAIClaude().callAIApi(baseBean());
        Test.stopTest();

        System.assertEquals('Error:HTTP 500 Internal Server Error', result, '解析不能時は HTTP ステータス+本文');
    }

    // =========================================================
    // 正常系: userkey が友だち(FmlLineMember)→ 保持され、ログが作成される
    //   - usage を返さないため (Decimal) キャストを経由せず 0 トークンでログ作成される
    // =========================================================
    @IsTest
    static void testUserkey_Friend_LogCreated() {
        bfml__FmlLineMember__c lm = new bfml__FmlLineMember__c();
        insert lm;

        ClaudeMock mock = new ClaudeMock(200, bodyText('こんにちは'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        bean.userkey = lm.Id;

        Test.startTest();
        String result = new FmlAIClaude().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('こんにちは', result);

        List<bfml__FmlChatGPTLog__c> logs = [
            SELECT Id, bfml__Model__c, bfml__Total_tokens__c, bfml__LineMemberID__c
            FROM bfml__FmlChatGPTLog__c
        ];
        System.assertEquals(1, logs.size(), '友だちの場合ログが1件作成される');
        System.assertEquals(DEFAULT_MODEL, logs[0].bfml__Model__c, 'ログにモデルが記録される');
        System.assert(logs[0].bfml__Total_tokens__c == 0, 'usage 無しのため 0 トークン');
        System.assertEquals(
            String.valueOf(lm.Id),
            String.valueOf(logs[0].bfml__LineMemberID__c),
            '友だちの ID が保持される'
        );
    }

    // =========================================================
    // 正常系: userkey が FmlLineMember 以外 → null 化される
    // =========================================================
    @IsTest
    static void testUserkey_NotFriend_Nulled() {
        ClaudeMock mock = new ClaudeMock(200, bodyText('応答'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        bean.userkey = UserInfo.getUserId(); // User の ID → 友だちではない

        Test.startTest();
        String result = new FmlAIClaude().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('応答', result);

        List<bfml__FmlChatGPTLog__c> logs = [
            SELECT Id, bfml__LineMemberID__c FROM bfml__FmlChatGPTLog__c
        ];
        System.assertEquals(1, logs.size(), 'ログは作成される');
        System.assertEquals(null, logs[0].bfml__LineMemberID__c, '友だち以外は LineMemberID が null');
    }

    // =========================================================
    // 正常系: userkey が ID 形式でない → 例外を握りつぶし null 化
    // =========================================================
    @IsTest
    static void testUserkey_InvalidId() {
        Test.setMock(HttpCalloutMock.class, new ClaudeMock(200, bodyText('応答')));

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        bean.userkey = 'not-an-id';

        Test.startTest();
        String result = new FmlAIClaude().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('応答', result, '不正 userkey でも処理継続');
    }

    // =========================================================
    // 正常系: usage を含むレスポンスでも応答テキストを返す
    //   - usage.input_tokens/output_tokens の集計分岐を通す
    // =========================================================
    @IsTest
    static void testUsagePresent_ReturnsText() {
        Test.setMock(HttpCalloutMock.class, new ClaudeMock(200, bodyTextWithUsage('応答')));

        Test.startTest();
        String result = new FmlAIClaude().callAIApi(baseBean());
        Test.stopTest();

        System.assertEquals('応答', result);
    }
}

AgentForce連携拡張メソッド参考例:

/**
 * Salesforce Einstein Models API (aiplatform.ModelsAPI) 実装クラス
 *
 * - aiplatform.ModelsAPI.createGenerations を呼び出してLLM応答を取得
 * - HTTPコールアウトを使わない(リモートサイト/Named Credential 不要)
 * - Einstein Trust Layer (PIIマスキング/監査) 自動適用
 * - ステートレスAPIのため、会話履歴はプロンプトに連結して送信
 * - callAIApi() の戻り値はAI応答テキスト(エラー時は 'Error:...' 形式)
 *
 * 【前提条件】
 *   - 組織で Einstein Generative AI / Models API が有効
 *   - 実行ユーザーに必要な権限セット(Prompt Template User 等)が付与
 */
global with sharing class FmlAIAgentforce extends bfml.FmlExternalIF {

    // カスタム設定から取得(デフォルトモデル)
    private static final String DEFAULT_MODEL = 'sfdc_ai__DefaultGPT4Omni';//DX_LINE_SETTING__c.getOrgDefaults().AIPLATFORM_MODEL_NAME__c;

    // =============================================
    // callAIApi 実装
    // =============================================

    /**
     * Models API を呼び出してAI応答を返す
     *
     * @param  bean リクエスト情報
     * @return      AI応答テキスト(エラー時は 'Error:...' 形式)
     */
    override global String callAIApi(bfml.FmlExternalIF.IntentBean bean) {

        if (bean == null) {
            return 'Error:IntentBean null ERROR';
        }
        String modelName = String.isBlank(bean.model) ? DEFAULT_MODEL : bean.model;
        if (String.isBlank(modelName)) {
            return 'Error:モデル名が未設定です';
        }

        // ── プロンプト構築(会話履歴を連結) ──────
        String prompt = buildPrompt(bean);
        if (String.isBlank(prompt)) {
            return 'Error:プロンプトが空です';
        }

        String requestJson = '';
        String responseJson = '';

        try {
            // ── リクエスト構築 ────────────────────────
            aiplatform.ModelsAPI.createGenerations_Request request =
                new aiplatform.ModelsAPI.createGenerations_Request();
            request.modelName = modelName;

            aiplatform.ModelsAPI_GenerationRequest body =
                new aiplatform.ModelsAPI_GenerationRequest();
            body.prompt = prompt;

            // 追加パラメータ(bean.parameters 経由で上書き可能)
            applyOptionalParameters(body, bean);

            request.body = body;

            // ログ用JSON(body + 元のbean設定を併記して可観測性を確保)
            requestJson = JSON.serialize(new Map<String, Object> {
                'modelName'  => modelName,
                'body'       => body,
                'beanParams' => new Map<String, Object> {
                    'temperature'       => bean.temperature,
                    'max_tokens'        => bean.max_tokens,
                    'top_p'             => bean.top_p,
                    'frequency_penalty' => bean.frequency_penalty,
                    'presence_penalty'  => bean.presence_penalty,
                    'stream'            => bean.stream
                }
            });

            // ── API呼び出し ───────────────────────────
            aiplatform.ModelsAPI api = new aiplatform.ModelsAPI();
            aiplatform.ModelsAPI.createGenerations_Response response =
                api.createGenerations(request);

            // ── レスポンス処理 ────────────────────────
            String outputText = extractOutputText(response);
            Decimal totalTokens = extractTokenCount(response);
            responseJson = JSON.serialize(response.Code200);

            // ── ログ記録 ──────────────────────────────
            saveLog(bean, requestJson, responseJson, modelName, totalTokens);

            if (String.isBlank(outputText)) {
                return 'Error:応答テキストが空です';
            }
            return outputText;

        } catch (Exception e) {
            // Models API 例外 / その他例外をまとめて捕捉
            String errMsg = e.getTypeName() + ': ' + e.getMessage();
            saveLog(bean, requestJson, errMsg, modelName, 0);
            return 'Error:' + errMsg;
        }
    }

    // =============================================
    // private: プロンプト構築
    // =============================================

    /**
     * IntentBean からプロンプト文字列を組み立て
     *
     *  優先順位:
     *   1) bean.messages (List<MessageBean>) が存在 → role別に整形連結
     *   2) bean.systemtext / usertext / assistanttext から組み立て
     */
    private String buildPrompt(bfml.FmlExternalIF.IntentBean bean) {

        List<String> lines = new List<String>();

        // ── (1) messages 優先 ────────────────────────
        if (bean.messages != null && !bean.messages.isEmpty()) {
            for (bfml.FmlExternalIF.MessageBean msg : bean.messages) {
                if (msg == null) continue;
                String text = contentToString(msg.content);
                if (String.isBlank(text)) continue;
                lines.add(formatRoleLine(msg.role, text));
            }
        } else {
            // ── (2) systemtext / usertext / assistanttext フォールバック ──
            if (String.isNotBlank(bean.systemtext)) {
                lines.add(formatRoleLine('system', bean.systemtext));
            }
            if (String.isNotBlank(bean.assistanttext)) {
                lines.add(formatRoleLine('assistant', bean.assistanttext));
            }
            if (String.isNotBlank(bean.usertext)) {
                lines.add(formatRoleLine('user', bean.usertext));
            }
        }

        if (lines.isEmpty()) return null;

        // 最後にAssistantの応答を促す
        lines.add('Assistant:');
        return String.join(lines, '\n');
    }

    /**
     * MessageBean.content (Object型) を安全に文字列化
     *  - String     → そのまま
     *  - List/Map   → JSON文字列
     *  - その他      → String.valueOf
     */
    private String contentToString(Object content) {
        if (content == null) return null;
        if (content instanceof String) return (String) content;
        try {
            return JSON.serialize(content);
        } catch (Exception e) {
            return String.valueOf(content);
        }
    }

    /**
     * role に応じてプロンプト1行を整形
     */
    private String formatRoleLine(String role, String text) {
        if ('system'.equalsIgnoreCase(role)) {
            return '[Instruction]\n' + text;
        } else if ('assistant'.equalsIgnoreCase(role)) {
            return 'Assistant: ' + text;
        }
        return 'User: ' + text;
    }

    /**
     * bean.parameters (JSON文字列) から任意パラメータをbodyに反映
     *  例: {"localization": {...}, "tags": {...}}
     *  ※ ModelsAPI_GenerationRequest が公開しているのは prompt / localization / tags のみ
     */
    private void applyOptionalParameters(aiplatform.ModelsAPI_GenerationRequest body,
                                         bfml.FmlExternalIF.IntentBean bean) {
        if (String.isBlank(bean.parameters)) return;
        try {
            // ModelsAPI_Localization / ModelsAPI_Tags の構造はSDKに依存するため、
            // JSON経由で安全にデシリアライズして反映
            Map<String, Object> p = (Map<String, Object>) JSON.deserializeUntyped(bean.parameters);

            if (p.containsKey('localization')) {
                body.localization = (aiplatform.ModelsAPI_Localization)
                    JSON.deserialize(JSON.serialize(p.get('localization')),
                                     aiplatform.ModelsAPI_Localization.class);
            }
            if (p.containsKey('tags')) {
                body.tags = (aiplatform.ModelsAPI_Tags)
                    JSON.deserialize(JSON.serialize(p.get('tags')),
                                     aiplatform.ModelsAPI_Tags.class);
            }
        } catch (Exception e) {
            System.debug('applyOptionalParameters error: ' + e.getMessage());
        }
    }

    // =============================================
    // private: レスポンス処理
    // =============================================

    /**
     * 生成結果テキストを取り出し
     *  response.Code200.generation.generatedText
     */
    private String extractOutputText(aiplatform.ModelsAPI.createGenerations_Response response) {
        if (response == null || response.Code200 == null) return null;

        aiplatform.ModelsAPI_GenerationResponse res = response.Code200;
        if (res.generation == null) return null;

        return res.generation.generatedText;
    }

    /**
     * トークン使用数を取得
     * ※ ModelsAPI_GenerationResponse には標準でusage情報がない場合があるため、
     *   JSON経由で動的に取得を試みる
     */
    private Decimal extractTokenCount(aiplatform.ModelsAPI.createGenerations_Response response) {
        try {
            if (response == null || response.Code200 == null) return 0;
            String resJson = JSON.serialize(response.Code200);
            Map<String, Object> m = (Map<String, Object>) JSON.deserializeUntyped(resJson);

            Object gen = m.get('generation');
            if (gen instanceof Map<String, Object>) {
                Object usage = ((Map<String, Object>) gen).get('parameters');
                if (usage instanceof Map<String, Object>) {
                    Object usageMap = ((Map<String, Object>) usage).get('usage');
                    if (usageMap instanceof Map<String, Object>) {
                        Map<String, Object> u = (Map<String, Object>) usageMap;
                        Object inp = u.get('prompt_tokens');
                        Object out = u.get('completion_tokens');
                        return (inp != null ? (Integer) inp : 0)
                             + (out != null ? (Integer) out : 0);
                    }
                }
            }
        } catch (Exception e) {
            System.debug('extractTokenCount error: ' + e.getMessage());
        }
        return 0;
    }

    // =============================================
    // private: ログ保存
    // =============================================
    private bfml__FmlChatGPTLog__c saveLog(bfml.FmlExternalIF.IntentBean bean, String requestJson,
                                           String responseJson, String modelName, Decimal totalTokens) {
        try {
            // userkeyが友だち以外のレコードIDなら除去
            try {
                String objName = Id.valueOf(bean.userkey).getsobjecttype().getDescribe().getName();
                if (!bfml__FmlLineMember__c.getSObjectType().getDescribe().getName().equals(objName)) {
                    bean.userkey = null;
                }
            } catch (Exception e) {
                bean.userkey = null;
                System.debug(e.getMessage());
            }

            bfml__FmlChatGPTLog__c log = new bfml__FmlChatGPTLog__c(
                bfml__Model__c        = 'ModelsAPI:' + modelName,
                bfml__Request__c      = requestJson,
                bfml__Response__c     = responseJson,
                bfml__Total_tokens__c = totalTokens,
                bfml__LineMemberID__c = bean.userkey
            );

            if (Schema.sObjectType.bfml__FmlChatGPTLog__c.isCreateable()) {
                insert log;
                return log;
            }
        } catch (Exception e) {
            System.debug(e.getMessage());
            return null;
        }
        return null;
    }
}

Google Gemini 連携拡張メソッド参考例:

/**
 * Google Gemini (Generative Language API) 実装クラス ── DX-LINE 用
 *
 * - FmlExternalIF インタフェースを実装
 * - Gemini generateContent API
 *   ( https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent ) を呼び出す
 * - Claude / OpenAI 版との主な差異:
 *     1. モデル名をリクエストボディではなく URL パスに含める (.../models/{model}:generateContent)
 *     2. 認証ヘッダーが「x-goog-api-key」(Bearer / x-api-key ではない)
 *     3. system プロンプトはトップレベル「system_instruction.parts[].text」に置く
 *     4. メッセージは「contents」配列。role は user / model、本文は parts[].text
 *        (アシスタントロールは「assistant」ではなく「model」と表現する)
 *     5. 生成パラメータは「generationConfig」内 (maxOutputTokens など camelCase)
 *     6. generateContent は非ストリーミングのため stream は送らない
 *     7. レスポンスは candidates[0].content.parts[].text、トークンは usageMetadata
 *        (totalTokenCount をそのまま利用。無ければ prompt + candidates を合算)
 *
 * ★ global: global interface FmlExternalIF を実装するクラスは global にする必要がある
 *           callAIApi メソッドも global で宣言する
 */
global with sharing class FmlAIGemini extends bfml.FmlExternalIF {

    //'https://generativelanguage.googleapis.com' を指す名前付き資格情報
    private static final String API_BASE = 'callout:gemini';

    //'AIzaSyXXXXXXXXXXXXXXXXXXXXXXXX';
    private static final String API_KEY = 'AIza-XXXXXXXXX';

    //'gemini-2.5-flash' / 'gemini-2.5-pro' など
    private static final String API_MODEL = 'gemini-2.5-flash';

    //Gemini も最大トークンは generationConfig.maxOutputTokens で指定するためデフォルト値を用意
    private static final Integer DEFAULT_MAX_TOKENS = 1024;

    /**
     * Gemini generateContent API を呼び出す
     * @param  bean リクエスト情報
     * @return      AI応答文字列
     */
    override global String callAIApi(bfml.FmlExternalIF.IntentBean bean) {

        if(null == bean) {
            return 'Error:IntentBean null ERROR';
        }

        if(String.isBlank(bean.model)) {
            bean.model = API_MODEL;
        }

        // HTTPリクエストの作成
        // ★ Claude / OpenAI と異なり、モデル名はボディではなく URL パスに含める
        HttpRequest req = new HttpRequest();
        // req.setEndpoint('https://generativelanguage.googleapis.com/v1beta/models/' + bean.model + ':generateContent');
        req.setEndpoint(FmlAIGemini.API_BASE + '/v1beta/models/' + bean.model + ':generateContent');
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');
        req.setTimeout(50000);
        // Gemini 認証ヘッダー (Bearer / x-api-key ではない)
        // req.setHeader('x-goog-api-key', FmlAIGemini.API_KEY); //Salesforceのcallout: 方式に変更をおすすめ
        req.setHeader('x-goog-api-key', '{!$Credential.gemini.apikey}');

        // ──────────────────────────────────────────────
        // OpenAI 形式の messages を Gemini 形式へ変換する。
        //  - system ロール    → トップレベル system_instruction へ分離
        //  - user ロール      → contents (role=user)
        //  - assistant ロール → contents (role=model)  ※Gemini は model と表現する
        // ──────────────────────────────────────────────
        String systemPrompt = '';
        List<Map<String, Object>> contents = new List<Map<String, Object>>();

        if(bean.messages != null) {
            for(bfml.FmlExternalIF.MessageBean msg : bean.messages) {
                if(msg == null || String.isBlank(msg.role)) continue;

                String contentText = (msg.content instanceof String)
                                     ? (String)msg.content
                                     : JSON.serialize(msg.content);

                if('system'.equalsIgnoreCase(msg.role)) {
                    systemPrompt += (String.isBlank(systemPrompt) ? '' : '\n') + contentText;
                } else if('user'.equalsIgnoreCase(msg.role)) {
                    contents.add(buildContent('user', contentText));
                } else if('assistant'.equalsIgnoreCase(msg.role) || 'model'.equalsIgnoreCase(msg.role)) {
                    contents.add(buildContent('model', contentText));
                }
            }
        }

        // maxOutputTokens は未指定 or 0 以下ならデフォルト値を使う
        Integer maxTokens = (bean.max_tokens != null && bean.max_tokens > 0)
                            ? (Integer)bean.max_tokens
                            : DEFAULT_MAX_TOKENS;

        // リクエストボディの設定
        // model はパスに含めるためボディには入れない / generateContent は stream を受け付けない
        Map<String, Object> body = new Map<String, Object>{
            'contents'         => contents,
            'generationConfig' => new Map<String, Object>{
                'maxOutputTokens' => maxTokens
            }
        };

        if(String.isNotBlank(systemPrompt)) {
            body.put('system_instruction', new Map<String, Object>{
                'parts' => new List<Object>{
                    new Map<String, Object>{ 'text' => systemPrompt }
                }
            });
        }

        // オプション追加セットする (parameters で渡された値が優先される)
        // ★ 生成パラメータを調整する場合は {"generationConfig":{...}} の形で渡す
        try {
            if(String.isNotBlank(bean.parameters)) {
                Map<String, Object> parameters =
                    (Map<String, Object>)JSON.deserializeUntyped(bean.parameters);
                body.putAll(parameters);
            }
        } catch(Exception e) {
            System.debug('parameters parse error');
        }

        String requestBody = JSON.serialize(body);

        req.setBody(requestBody);

        // HTTPリクエストの送信
        Http http = new Http();
        HttpResponse res = http.send(req);

        // 応答の処理
        if (res.getStatusCode() == 200) {
            Map<String, Object> resBody =
                (Map<String, Object>) JSON.deserializeUntyped(res.getBody());

            if (resBody.containsKey('candidates')) {
                List<Object> candidates = (List<Object>)resBody.get('candidates');

                // candidates[0].content.parts[] のうち text を連結して回答とする
                // (functionCall 等 text を持たないパートは無視する)
                String retMsg = '';
                String finishReason = '';
                if(!candidates.isEmpty()) {
                    Map<String, Object> firstCandidate = (Map<String, Object>)candidates[0];
                    finishReason = (String)firstCandidate.get('finishReason');

                    Map<String, Object> content = (Map<String, Object>)firstCandidate.get('content');
                    if(content != null && content.get('parts') != null) {
                        for(Object p : (List<Object>)content.get('parts')) {
                            Map<String, Object> part = (Map<String, Object>)p;
                            if(part.get('text') != null) {
                                retMsg += (String)part.get('text');
                            }
                        }
                    }
                }

                //Iuserkey は友だちの場合のみ保持する (ChatGPT 実装と同じロジック)
                try {
                    String objName = Id.valueOf(bean.userkey).getsobjecttype()
                                       .getDescribe().getName();
                    //友だち詳細ページの場合
                    if(!bfml__FmlLineMember__c.getSObjectType().getDescribe().getName().equals(objName)) {
                        bean.userkey = null;
                    }
                } catch(Exception e) {
                    bean.userkey = null;
                    System.debug(e.getMessage());
                }

                try {
                    // usageMetadata からトークン数を取得
                    // Gemini は totalTokenCount を直接返す (無ければ prompt + candidates を合算)
                    // ※ JSON.deserializeUntyped の整数は Integer 型のため (Integer) でキャストする
                    Decimal totalTokens = 0;
                    if(resBody.containsKey('usageMetadata')) {
                        Map<String, Object> usage = (Map<String, Object>)resBody.get('usageMetadata');
                        if(usage.get('totalTokenCount') != null) {
                            totalTokens = (Integer)usage.get('totalTokenCount');
                        } else {
                            Integer promptTok = (usage.get('promptTokenCount') != null)
                                                ? (Integer)usage.get('promptTokenCount') : 0;
                            Integer candTok   = (usage.get('candidatesTokenCount') != null)
                                                ? (Integer)usage.get('candidatesTokenCount') : 0;
                            totalTokens = promptTok + candTok;
                        }
                    }

                    //ログ作成 (ChatGPT 用ログオブジェクトを流用)
                    bfml__FmlChatGPTLog__c log = new bfml__FmlChatGPTLog__c(
                        bfml__Model__c        = bean.model,
                        bfml__Request__c      = requestBody,
                        bfml__Response__c     = res.getBody().toString(),
                        bfml__Total_tokens__c = totalTokens,
                        bfml__LineMemberID__c = bean.userkey
                    );
                    if (Schema.sObjectType.bfml__FmlChatGPTLog__c.isCreateable()) {
                        insert log;//ok
                    }
                } catch(Exception e) {
                    system.debug(e.getMessage());
                }

                if(String.isBlank(retMsg)) {
                    // finishReason が MAX_TOKENS の場合は枠が足りていない可能性が高い
                    if('MAX_TOKENS'.equalsIgnoreCase(finishReason)) {
                        return 'Error:プロンプトのパラメータ:「max_tokens」を拡大してみてください。';
                    }
                    return 'Error:応答が空でした。';
                } else {
                    return retMsg;
                }
            }
        } else {
            // エラー応答 ( {"error":{"code":...,"message":"...","status":"..."}} )
            try {
                Map<String, Object> resBody =
                    (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
                Map<String, Object> errorObj = (Map<String, Object>) resBody.get('error');
                String errorMessage = (String) errorObj.get('message');
                return 'Error:' + errorMessage;
            } catch(Exception e) {
                return 'Error:HTTP ' + res.getStatusCode() + ' ' + res.getBody();
            }
        }

        return 'Error: System Error Try Again';
    }

    /**
     * Gemini contents 要素 (role + parts[].text) を生成する
     * @param role メッセージロール (user / model)
     * @param text 本文
     */
    private Map<String, Object> buildContent(String role, String text) {
        return new Map<String, Object>{
            'role'  => role,
            'parts' => new List<Object>{
                new Map<String, Object>{ 'text' => text }
            }
        };
    }
}

//テストクラス
/**
 * FmlAIGemini のテストクラス
 *
 * - Gemini generateContent API 呼び出しを HttpCalloutMock で擬似化し、callAIApi の各分岐を検証する
 * - FmlAIGemini と同じく bfml 名前空間プレフィックス付きで参照し、サブスクライバ組織でも
 *   そのままデプロイできるようにしている
 * - 検証ポイント (Claude / OpenAI 実装との差異):
 *     1. モデル名が URL パス (.../models/{model}:generateContent) に含まれること
 *     2. model / stream をリクエストボディに含めないこと
 *     3. system プロンプトが system_instruction.parts[].text へ分離されること
 *     4. assistant ロールが contents の role=model へ変換されること
 *     5. generationConfig.maxOutputTokens がセットされること(未指定時はデフォルト 1024)
 *     6. candidates[0].content.parts[] の text のみ連結されること
 *     7. usageMetadata からトークン数が算出されること
 */
@IsTest
private class FmlAIGeminiTest {

    // 期待されるデフォルト値 (FmlAIGemini の private 定数と一致させる)
    private static final String DEFAULT_MODEL = 'gemini-2.5-flash';
    private static final Integer DEFAULT_MAX_TOKENS = 1024;

    // =========================================================
    // 設定可能な HTTP コールアウトモック
    //   - ステータスコード / レスポンスボディを指定して任意の応答を返す
    //   - 送信されたエンドポイント・リクエストボディ・主要ヘッダーを記録し検証に使う
    // =========================================================
    private class GeminiMock implements HttpCalloutMock {
        private Integer statusCode;
        private String responseBody;

        public String capturedEndpoint;     // 送信先エンドポイント
        public String capturedBody;          // 送信ボディ(JSON)
        public String capturedApiKeyHeader;  // x-goog-api-key
        public String capturedContentType;   // Content-Type

        public GeminiMock(Integer statusCode, String responseBody) {
            this.statusCode   = statusCode;
            this.responseBody = responseBody;
        }

        public HttpResponse respond(HttpRequest req) {
            this.capturedEndpoint     = req.getEndpoint();
            this.capturedBody         = req.getBody();
            this.capturedApiKeyHeader = req.getHeader('x-goog-api-key');
            this.capturedContentType  = req.getHeader('Content-Type');

            HttpResponse res = new HttpResponse();
            res.setStatusCode(this.statusCode);
            res.setBody(this.responseBody);
            res.setHeader('Content-Type', 'application/json');
            return res;
        }
    }

    // =========================================================
    // テストデータ生成ヘルパー
    // =========================================================

    /** system + user の標準メッセージを持つ IntentBean (model / max_tokens は未指定=デフォルト) */
    private static bfml.FmlExternalIF.IntentBean baseBean() {
        bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
        bean.stream = false;
        bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
            new bfml.FmlExternalIF.MessageBean('system', 'あなたは親切なアシスタントです。'),
            new bfml.FmlExternalIF.MessageBean('user', 'こんにちは')
        };
        return bean;
    }

    /** 1つの text パートを返す candidate (usageMetadata なし → ログは 0 トークンで作成される) */
    private static String bodyText(String text) {
        return JSON.serialize(new Map<String, Object>{
            'candidates'   => new List<Object>{
                new Map<String, Object>{
                    'content'      => new Map<String, Object>{
                        'role'  => 'model',
                        'parts' => new List<Object>{
                            new Map<String, Object>{ 'text' => text }
                        }
                    },
                    'finishReason' => 'STOP',
                    'index'        => 0
                }
            },
            'modelVersion' => 'gemini-2.5-flash'
        });
    }

    /** text パート + usageMetadata (totalTokenCount あり) */
    private static String bodyTextWithUsage(String text) {
        return JSON.serialize(new Map<String, Object>{
            'candidates'    => new List<Object>{
                new Map<String, Object>{
                    'content'      => new Map<String, Object>{
                        'parts' => new List<Object>{
                            new Map<String, Object>{ 'text' => text }
                        }
                    },
                    'finishReason' => 'STOP'
                }
            },
            'usageMetadata' => new Map<String, Object>{
                'promptTokenCount'     => 10,
                'candidatesTokenCount' => 20,
                'totalTokenCount'      => 30
            }
        });
    }

    /** text パート + usageMetadata (totalTokenCount なし → prompt + candidates を合算) */
    private static String bodyTextWithUsageNoTotal(String text) {
        return JSON.serialize(new Map<String, Object>{
            'candidates'    => new List<Object>{
                new Map<String, Object>{
                    'content'      => new Map<String, Object>{
                        'parts' => new List<Object>{
                            new Map<String, Object>{ 'text' => text }
                        }
                    },
                    'finishReason' => 'STOP'
                }
            },
            'usageMetadata' => new Map<String, Object>{
                'promptTokenCount'     => 7,
                'candidatesTokenCount' => 5
            }
        });
    }

    /** text 以外(functionCall)を含む複数パート (text のみ連結されることの検証用) */
    private static String bodyMultiParts() {
        return JSON.serialize(new Map<String, Object>{
            'candidates' => new List<Object>{
                new Map<String, Object>{
                    'content'      => new Map<String, Object>{
                        'parts' => new List<Object>{
                            new Map<String, Object>{ 'text' => '前半' },
                            new Map<String, Object>{ 'functionCall' => new Map<String, Object>{ 'name' => 'calc' } },
                            new Map<String, Object>{ 'text' => '後半' }
                        }
                    },
                    'finishReason' => 'STOP'
                }
            }
        });
    }

    /** content を持たない candidate (retMsg が空になる) レスポンス */
    private static String bodyEmptyText(String finishReason) {
        return JSON.serialize(new Map<String, Object>{
            'candidates' => new List<Object>{
                new Map<String, Object>{ 'finishReason' => finishReason }
            }
        });
    }

    /** candidates が空配列のレスポンス */
    private static String bodyEmptyCandidates() {
        return JSON.serialize(new Map<String, Object>{
            'candidates' => new List<Object>()
        });
    }

    /** candidates キーを持たないレスポンス */
    private static String bodyNoCandidates() {
        return JSON.serialize(new Map<String, Object>{
            'promptFeedback' => new Map<String, Object>{ 'blockReason' => 'SAFETY' }
        });
    }

    /** Gemini エラー形式のレスポンス */
    private static String bodyError(String message) {
        return JSON.serialize(new Map<String, Object>{
            'error' => new Map<String, Object>{
                'code'    => 400,
                'message' => message,
                'status'  => 'INVALID_ARGUMENT'
            }
        });
    }

    /** 送信されたリクエストボディを Map に復元する */
    private static Map<String, Object> parseSent(GeminiMock mock) {
        return (Map<String, Object>) JSON.deserializeUntyped(mock.capturedBody);
    }

    // =========================================================
    // 異常系: bean が null
    // =========================================================
    @IsTest
    static void testBeanNull() {
        Test.startTest();
        String result = new FmlAIGemini().callAIApi(null);
        Test.stopTest();

        System.assertEquals('Error:IntentBean null ERROR', result, 'null bean は専用エラーを返す');
    }

    // =========================================================
    // 正常系: デフォルトモデル / エンドポイント / リクエスト整形 / ヘッダー
    //   - model 未指定 → デフォルトモデルが URL パスに入る
    //   - max_tokens 未指定 → generationConfig.maxOutputTokens = 1024
    //   - system が system_instruction へ分離
    //   - contents は user のみ
    //   - model / stream はボディに含めない
    // =========================================================
    @IsTest
    static void testSuccess_DefaultModel_RequestShape() {
        GeminiMock mock = new GeminiMock(200, bodyText('はい、こんにちは。'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = baseBean(); // model / max_tokens 未指定

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('はい、こんにちは。', result, 'parts の text を返す');

        // ── エンドポイント検証 (モデル名が URL パスに含まれる) ──
        System.assert(
            mock.capturedEndpoint.contains('/v1beta/models/' + DEFAULT_MODEL + ':generateContent'),
            'モデル名が URL パスに含まれる: ' + mock.capturedEndpoint
        );

        // ── リクエストボディ検証 ──
        Map<String, Object> sent = parseSent(mock);
        System.assertEquals(false, sent.containsKey('model'),  'model はボディに含めない (URL パスに含める)');
        System.assertEquals(false, sent.containsKey('stream'), 'stream はボディに含めない');

        Map<String, Object> genConfig = (Map<String, Object>) sent.get('generationConfig');
        System.assertEquals(DEFAULT_MAX_TOKENS, (Integer) genConfig.get('maxOutputTokens'), 'maxOutputTokens 未指定時はデフォルト 1024');

        Map<String, Object> sysInst = (Map<String, Object>) sent.get('system_instruction');
        List<Object> sysParts = (List<Object>) sysInst.get('parts');
        System.assertEquals('あなたは親切なアシスタントです。', (String) ((Map<String, Object>) sysParts[0]).get('text'), 'system は system_instruction へ分離される');

        List<Object> contents = (List<Object>) sent.get('contents');
        System.assertEquals(1, contents.size(), 'contents は user のみ');
        Map<String, Object> c0 = (Map<String, Object>) contents[0];
        System.assertEquals('user', (String) c0.get('role'), 'role は user');
        List<Object> c0Parts = (List<Object>) c0.get('parts');
        System.assertEquals('こんにちは', (String) ((Map<String, Object>) c0Parts[0]).get('text'), 'parts[].text に本文が入る');

        // ── ヘッダー検証 ──
        System.assertEquals('application/json', mock.capturedContentType, 'Content-Type ヘッダー');
        System.assert(String.isNotBlank(mock.capturedApiKeyHeader), 'x-goog-api-key ヘッダーがセットされる');
    }

    // =========================================================
    // 正常系: model / max_tokens を明示指定
    // =========================================================
    @IsTest
    static void testSuccess_ProvidedModelAndMaxTokens() {
        GeminiMock mock = new GeminiMock(200, bodyText('応答'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        bean.model = 'gemini-2.5-pro';
        bean.max_tokens = 2048;

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('応答', result);
        System.assert(
            mock.capturedEndpoint.contains('/v1beta/models/gemini-2.5-pro:generateContent'),
            '指定モデルが URL パスに含まれる: ' + mock.capturedEndpoint
        );

        Map<String, Object> genConfig = (Map<String, Object>) parseSent(mock).get('generationConfig');
        System.assertEquals(2048, (Integer) genConfig.get('maxOutputTokens'), '指定 max_tokens が使われる');
    }

    // =========================================================
    // 正常系: max_tokens = 0 はデフォルトへフォールバック
    // =========================================================
    @IsTest
    static void testMaxTokensZero_UsesDefault() {
        GeminiMock mock = new GeminiMock(200, bodyText('ok'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        bean.max_tokens = 0; // 0 以下はデフォルト扱い

        Test.startTest();
        new FmlAIGemini().callAIApi(bean);
        Test.stopTest();

        Map<String, Object> genConfig = (Map<String, Object>) parseSent(mock).get('generationConfig');
        System.assertEquals(DEFAULT_MAX_TOKENS, (Integer) genConfig.get('maxOutputTokens'), '0 指定時はデフォルト 1024');
    }

    // =========================================================
    // 正常系: 複数パートのうち text のみ連結 (functionCall は無視)
    // =========================================================
    @IsTest
    static void testMultipleTextParts_Concatenated() {
        Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyMultiParts()));

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(baseBean());
        Test.stopTest();

        System.assertEquals('前半後半', result, 'functionCall を除き text パートのみ連結される');
    }

    // =========================================================
    // 正常系: assistant ロールが contents の role=model へ変換される
    // =========================================================
    @IsTest
    static void testAssistantRole_MappedToModel() {
        GeminiMock mock = new GeminiMock(200, bodyText('ok'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
        bean.stream = false;
        bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
            new bfml.FmlExternalIF.MessageBean('user', '質問'),
            new bfml.FmlExternalIF.MessageBean('assistant', '前回の回答'),
            new bfml.FmlExternalIF.MessageBean('user', '追加質問')
        };

        Test.startTest();
        new FmlAIGemini().callAIApi(bean);
        Test.stopTest();

        List<Object> contents = (List<Object>) parseSent(mock).get('contents');
        System.assertEquals(3, contents.size(), 'user / assistant / user の3件が contents に入る');
        System.assertEquals('user',  (String) ((Map<String, Object>) contents[0]).get('role'));
        System.assertEquals('model', (String) ((Map<String, Object>) contents[1]).get('role'), 'assistant は model に変換される');
        System.assertEquals('user',  (String) ((Map<String, Object>) contents[2]).get('role'));
    }

    // =========================================================
    // 正常系: system メッセージが複数 → 改行で連結
    // =========================================================
    @IsTest
    static void testMultipleSystemMessages_Joined() {
        GeminiMock mock = new GeminiMock(200, bodyText('ok'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
        bean.stream = false;
        bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
            new bfml.FmlExternalIF.MessageBean('system', 'sys1'),
            new bfml.FmlExternalIF.MessageBean('system', 'sys2'),
            new bfml.FmlExternalIF.MessageBean('user', 'hi')
        };

        Test.startTest();
        new FmlAIGemini().callAIApi(bean);
        Test.stopTest();

        Map<String, Object> sysInst = (Map<String, Object>) parseSent(mock).get('system_instruction');
        List<Object> sysParts = (List<Object>) sysInst.get('parts');
        System.assertEquals('sys1\nsys2', (String) ((Map<String, Object>) sysParts[0]).get('text'), '複数 system は改行連結される');
    }

    // =========================================================
    // 正常系: system の content が String 以外 → JSON 直列化される
    // =========================================================
    @IsTest
    static void testSystemContentNonString_Serialized() {
        GeminiMock mock = new GeminiMock(200, bodyText('ok'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
        bean.stream = false;
        bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
            new bfml.FmlExternalIF.MessageBean('system', new Map<String, Object>{ 'k' => 'v' }),
            new bfml.FmlExternalIF.MessageBean('user', 'hi')
        };

        Test.startTest();
        new FmlAIGemini().callAIApi(bean);
        Test.stopTest();

        Map<String, Object> sysInst = (Map<String, Object>) parseSent(mock).get('system_instruction');
        List<Object> sysParts = (List<Object>) sysInst.get('parts');
        System.assertEquals('{"k":"v"}', (String) ((Map<String, Object>) sysParts[0]).get('text'), 'String 以外の content は JSON 直列化される');
    }

    // =========================================================
    // 正常系: messages が null でも例外なく呼び出せる
    // =========================================================
    @IsTest
    static void testMessagesNull() {
        GeminiMock mock = new GeminiMock(200, bodyText('応答'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
        bean.stream = false;
        bean.messages = null;

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('応答', result);

        Map<String, Object> sent = parseSent(mock);
        System.assertEquals(false, sent.containsKey('system_instruction'), 'system_instruction は付与されない');
        System.assertEquals(0, ((List<Object>) sent.get('contents')).size(), 'contents は空');
    }

    // =========================================================
    // 正常系: null 要素 / role 空白は除外される
    // =========================================================
    @IsTest
    static void testNullAndBlankRoleEntriesSkipped() {
        GeminiMock mock = new GeminiMock(200, bodyText('ok'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = new bfml.FmlExternalIF.IntentBean();
        bean.stream = false;
        bean.messages = new List<bfml.FmlExternalIF.MessageBean>{
            null,                                                  // null 要素 → skip
            new bfml.FmlExternalIF.MessageBean('', 'blank role'),  // role 空白 → skip
            new bfml.FmlExternalIF.MessageBean('user', 'hi')       // user のみ採用
        };

        Test.startTest();
        new FmlAIGemini().callAIApi(bean);
        Test.stopTest();

        List<Object> contents = (List<Object>) parseSent(mock).get('contents');
        System.assertEquals(1, contents.size(), 'user メッセージのみが残る');
        System.assertEquals('user', (String) ((Map<String, Object>) contents[0]).get('role'));
    }

    // =========================================================
    // 正常系: parameters が body へマージ(トップレベル)される
    // =========================================================
    @IsTest
    static void testParametersOverride() {
        GeminiMock mock = new GeminiMock(200, bodyText('ok'));
        Test.setMock(HttpCalloutMock.class, mock);

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        // safetySettings はトップレベル項目 / generationConfig は丸ごと上書きされる
        bean.parameters = '{"safetySettings":[{"category":"HARM_CATEGORY_HARASSMENT","threshold":"BLOCK_NONE"}],'
                        + '"generationConfig":{"temperature":0.3,"maxOutputTokens":2000}}';

        Test.startTest();
        new FmlAIGemini().callAIApi(bean);
        Test.stopTest();

        Map<String, Object> sent = parseSent(mock);
        System.assertEquals(true, sent.containsKey('safetySettings'), 'parameters のトップレベル項目が追加される');

        Map<String, Object> genConfig = (Map<String, Object>) sent.get('generationConfig');
        System.assertEquals(2000, (Integer) genConfig.get('maxOutputTokens'), 'parameters の generationConfig が既存値を上書きする');
        System.assertEquals(true, genConfig.containsKey('temperature'), 'temperature が反映される');
    }

    // =========================================================
    // 正常系: parameters が不正 JSON でも握りつぶして継続
    // =========================================================
    @IsTest
    static void testParametersInvalidJson() {
        Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyText('応答')));

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        bean.parameters = '{invalid json';

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('応答', result, '不正 parameters は無視され処理継続');
    }

    // =========================================================
    // 異常系: 応答が空 + finishReason=MAX_TOKENS
    // =========================================================
    @IsTest
    static void testEmptyText_FinishMaxTokens() {
        Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyEmptyText('MAX_TOKENS')));

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(baseBean());
        Test.stopTest();

        System.assertEquals('Error:プロンプトのパラメータ:「max_tokens」を拡大してみてください。', result);
    }

    // =========================================================
    // 異常系: 応答が空 + finishReason がそれ以外 (SAFETY 等)
    // =========================================================
    @IsTest
    static void testEmptyText_FinishOther() {
        Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyEmptyText('SAFETY')));

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(baseBean());
        Test.stopTest();

        System.assertEquals('Error:応答が空でした。', result);
    }

    // =========================================================
    // 異常系: candidates が空配列
    // =========================================================
    @IsTest
    static void testEmptyCandidates() {
        Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyEmptyCandidates()));

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(baseBean());
        Test.stopTest();

        System.assertEquals('Error:応答が空でした。', result, 'candidates 空配列は空応答エラー');
    }

    // =========================================================
    // 異常系: 200 だが candidates キーが無い
    // =========================================================
    @IsTest
    static void testNoCandidatesKey() {
        Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyNoCandidates()));

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(baseBean());
        Test.stopTest();

        System.assertEquals('Error: System Error Try Again', result);
    }

    // =========================================================
    // 異常系: 非 200 + error.message を解析
    // =========================================================
    @IsTest
    static void testErrorResponse_WithMessage() {
        Test.setMock(HttpCalloutMock.class, new GeminiMock(400, bodyError('API key not valid')));

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(baseBean());
        Test.stopTest();

        System.assertEquals('Error:API key not valid', result, 'error.message を返す');
    }

    // =========================================================
    // 異常系: 非 200 + JSON 解析不能 → フォールバック
    // =========================================================
    @IsTest
    static void testErrorResponse_Fallback() {
        Test.setMock(HttpCalloutMock.class, new GeminiMock(503, 'Service Unavailable'));

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(baseBean());
        Test.stopTest();

        System.assertEquals('Error:HTTP 503 Service Unavailable', result, '解析不能時は HTTP ステータス+本文');
    }

    // =========================================================
    // 正常系: userkey が友だち(FmlLineMember)→ 保持され、ログが作成される
    //   - usageMetadata.totalTokenCount から 30 トークンが記録される
    // =========================================================
    @IsTest
    static void testUserkey_Friend_LogCreated_WithTokens() {
        bfml__FmlLineMember__c lm = new bfml__FmlLineMember__c();
        insert lm;

        Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyTextWithUsage('こんにちは')));

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        bean.userkey = lm.Id;

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('こんにちは', result);

        List<bfml__FmlChatGPTLog__c> logs = [
            SELECT Id, bfml__Model__c, bfml__Total_tokens__c, bfml__LineMemberID__c
            FROM bfml__FmlChatGPTLog__c
        ];
        System.assertEquals(1, logs.size(), '友だちの場合ログが1件作成される');
        System.assertEquals(DEFAULT_MODEL, logs[0].bfml__Model__c, 'ログにモデルが記録される');
        System.assert(logs[0].bfml__Total_tokens__c == 30, 'totalTokenCount の 30 が記録される');
        System.assertEquals(
            String.valueOf(lm.Id),
            String.valueOf(logs[0].bfml__LineMemberID__c),
            '友だちの ID が保持される'
        );
    }

    // =========================================================
    // 正常系: totalTokenCount が無い場合は prompt + candidates を合算
    // =========================================================
    @IsTest
    static void testUsage_PromptPlusCandidates() {
        bfml__FmlLineMember__c lm = new bfml__FmlLineMember__c();
        insert lm;

        Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyTextWithUsageNoTotal('応答')));

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        bean.userkey = lm.Id;

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('応答', result);

        List<bfml__FmlChatGPTLog__c> logs = [
            SELECT Id, bfml__Total_tokens__c FROM bfml__FmlChatGPTLog__c
        ];
        System.assertEquals(1, logs.size());
        System.assert(logs[0].bfml__Total_tokens__c == 12, 'prompt(7) + candidates(5) = 12 が記録される');
    }

    // =========================================================
    // 正常系: userkey が FmlLineMember 以外 → null 化される
    // =========================================================
    @IsTest
    static void testUserkey_NotFriend_Nulled() {
        Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyText('応答')));

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        bean.userkey = UserInfo.getUserId(); // User の ID → 友だちではない

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('応答', result);

        List<bfml__FmlChatGPTLog__c> logs = [
            SELECT Id, bfml__LineMemberID__c FROM bfml__FmlChatGPTLog__c
        ];
        System.assertEquals(1, logs.size(), 'ログは作成される');
        System.assertEquals(null, logs[0].bfml__LineMemberID__c, '友だち以外は LineMemberID が null');
    }

    // =========================================================
    // 正常系: userkey が ID 形式でない → 例外を握りつぶし null 化
    // =========================================================
    @IsTest
    static void testUserkey_InvalidId() {
        Test.setMock(HttpCalloutMock.class, new GeminiMock(200, bodyText('応答')));

        bfml.FmlExternalIF.IntentBean bean = baseBean();
        bean.userkey = 'not-an-id';

        Test.startTest();
        String result = new FmlAIGemini().callAIApi(bean);
        Test.stopTest();

        System.assertEquals('応答', result, '不正 userkey でも処理継続');
    }
}

タイトルとURLをコピーしました