💭

AWS Bedrock × Laravel:Sonnet 4.5 / 3.5 / gpt-oss の正しい呼び出し方

に公開

TL;DR

  • AWS Bedrock は複数のAIモデル(gpt-oss / Claude 3.5 / Claude 4.5 など)への共通ゲートウェイ。
    • APIキーの個別管理が不要になり、請求の一本化+IAM制御が可能になる。
    • ただしモデルごとに“呼び出し仕様が異なる”のが最大の注意点。
    • Sonnet 3.5以上は推論プロファイルを経由したコールが必須

Amazon Bedrockとは

各AIモデルをコールする中間に位置しているサービスでありクライアントはこのBedrockを通じて、以下のようなモデルにアクセス可能である

  • gpt-oss-120b
  • Claude Sonnet 3.5
  • Claude Sonnet 4.5
  • Claude Sonnet 4.5 Haiku

など

それぞれのプロバイダーに直接契約してAPIキーを貰えばいいじゃないかという話もあるが、Bedrockをカマせる事で

  • 請求をAWSに一本化できる
  • IAM権限で使える使えないを管理できる

というメリットがあるし、何よりAWSアカウントを契約していれば個別のエンジンをぞれそれ追加契約する必要がなくAIエンジンが使えるというのが1つの売りなんだと思う。ただし、実際には結構クセが強いのでサンプルプログラムと共に実装およびその挙動を見てみるというのが本稿の趣旨の1つとなる。

まずはIAMが必要

何にせよ、Bedrockを起動するためのIAMが必要である。開発で使うのであればすべての foundation-model を許可すべての inference profile(推論プロファイル) を許可をしておくと後々困らない。両者の違いは後ほど説明する。

以下はcdkで表現した例

    // Policy for Bedrock access
    const bedrockPolicy = new iam.Policy(this, 'BedrockPolicy', {
      policyName: 'ExampleBedrockPolicy',
      statements: [
        new iam.PolicyStatement({
          effect: iam.Effect.ALLOW,
          actions: [
            'bedrock:InvokeModel',
            'bedrock:InvokeModelWithResponseStream',
          ],
          resources: [
            // すべての foundation-model を許可
            'arn:aws:bedrock:*::foundation-model/*',
            // このアカウントのすべての inference profile を許可
            'arn:aws:bedrock:*:<YourAWSID>:inference-profile/*',
          ],
        }),
      ],
    });

モデルの確認

ちょっと前まではモデルごとに申請が必要だったがもうその制度は消滅した。いずれにせよ「モデルカタログ」でモデルIDが取得できるのでいくつか見てみよう。

Claude 3.5 Sonnet の場合


anthropic.claude-3-5-sonnet-20240620-v1:0

gpt-oss-120b の場合


openai.gpt-oss-120b-1:0

このようにモデルカタログのページより、モデルIDを取得する事ができる

接続テストコマンドを作ってみる

ここではLaravelを利用し、make:commandでそれぞれのコマンドを作って使い捨ての検証を行う。

必要なライブラリーと設定

aws/aws-sdk-phpが必須

composer require aws/aws-sdk-php

さらにconfig/services.phpに以下のようなエントリーを作成する

    'bedrock' => [
        'access_key' => env('AWS_ACCESS_KEY_ID'),
        'secret_key' => env('AWS_SECRET_ACCESS_KEY'),
        'region' => env('AWS_DEFAULT_REGION', 'ap-northeast-1'),
    ],

この状態で .env

  • AWS_ACCESS_KEY_ID
  • AWS_SECRET_ACCESS_KEY
  • AWS_DEFAULT_REGION

をセットする。もしBedrock専用のIAMが欲しければここは工夫していただきたい。

openai.gpt-oss-120bを使って接続する

以下のようにしてテストコマンドを作成する

php artisan make:command GptOssTest

クライアントの初期化

ここで、以下のように書き換える

app/Console/Commands/GptOssTest.php
@@ -3,6 +3,8 @@
 namespace App\Console\Commands;

 use Illuminate\Console\Command;
+use Aws\BedrockRuntime\BedrockRuntimeClient;
+use Aws\Exception\AwsException;

 class GptOssTest extends Command
 {
@@ -11,7 +13,7 @@ class GptOssTest extends Command
      *
      * @var string
      */
-    protected $signature = 'app:gpt-oss-test';
+    protected $signature = 'bedrock:gpt-oss-test';

     /**
      * The console command description.
@@ -25,6 +27,23 @@ class GptOssTest extends Command
      */
     public function handle()
     {
-        //
+        $accessKey = config('services.bedrock.access_key');
+        $secretKey = config('services.bedrock.secret_key');
+        $region = config('services.bedrock.region');
+        $clientConfig = [
+            'region' => $region,
+            'version' => 'latest',
+            'credentials' => [
+                'key' => $accessKey,
+                'secret' => $secretKey,
+            ],
+        ];
+
+        try {
+            $client = new BedrockRuntimeClient($clientConfig);
+        } catch (\Throwable $e) {
+            $this->error('BedrockRuntimeClient の初期化に失敗しました: ' . $e->getMessage());
+            return Command::FAILURE;
+        }
     }
 }

ここで$clientという変数にBedrock問い合せクライアントが生成された。clientに渡すconfigの構造体は以下の通り

array:3 [
  "region" => "ap-northeast-1"
  "version" => "latest"
  "credentials" => array:2 [
    "key" => "AKI****"
    "secret" => "****"
  ]
]

handle()を書いていく

app/Console/Commands/GptOssTest.php
@@ -2,6 +2,8 @@

 namespace App\Console\Commands;

+use Aws\BedrockRuntime\BedrockRuntimeClient;
+use Aws\Exception\AwsException;
 use Illuminate\Console\Command;

 class GptOssTest extends Command
@@ -25,6 +27,17 @@ class GptOssTest extends Command
      */
     public function handle()
     {
-        //
+        $accessKey = config('services.bedrock.access_key');
+        $secretKey = config('services.bedrock.secret_key');
+        $region = config('services.bedrock.region');
+        $clientConfig = [
+            'region' => $region,
+            'version' => 'latest',
+            'credentials' => [
+                'key' => $accessKey,
+                'secret' => $secretKey,
+            ],
+        ];
+        dd($clientConfig);
     }
 }

本来はライブラリーの読み込みチェックを書けた方がいいかもだけどエラー処理を甘く書くとこんな感じになる。

作成されたclientで問い合わせる

app/Console/Commands/GptOssTest.php
@@ -45,5 +45,50 @@ public function handle()
             $this->error('BedrockRuntimeClient の初期化に失敗しました: ' . $e->getMessage());
             return Command::FAILURE;
         }
+
+        $modelId = 'openai.gpt-oss-120b-1:0';
+        $message = 'こんにちわ、メッセージを受信できていますか?';
+
+        $data = [
+            'messages' => [
+                [
+                    'role' => 'system',
+                    'content' => 'You are a friendly assistant used for health
checks. Keep responses brief.',
+                ],
+                [
+                    'role' => 'user',
+                    'content' => $message,
+                ],
+            ],
+            'max_tokens' => 200,
+            'temperature' => 0.2,
+        ];
+
+        $payload = [
+            'accept' => 'application/json',
+            'contentType' => 'application/json',
+            'body' => json_encode($data),
+        ];
+
+        $this->info('Bedrockへ問い合わせ中...');
+
+        try {
+            $result = $client->invokeModel([
+                'modelId' => $modelId,
+                'accept' => 'application/json',
+                'contentType' => 'application/json',
+                'body' => $payload['body'],
+            ]);
+        } catch (\Throwable $e) {
+            $this->newLine();
+            $this->error('Bedrock呼び出し中に想定外のエラー: ' . $e->getMessage());
+            return Command::FAILURE;
+        }
+        $rawBody = (string) $result->get('body')->getContents();
+        $responseData = json_decode($rawBody, true);
+        dump($responseData);
+
+
     }
 }

これを実行すればよいのだが、ここでpayloadに渡すデーター構造体は以下の通りである。

array:4 [
  "messages" => array:2 [
    0 => array:2 [
      "role" => "system"
      "content" => "You are a friendly assistant used for health checks. Keep responses brief."
    ]
    1 => array:2 [
      "role" => "user"
      "content" => "こんにちわ、メッセージを受信できていますか?"
    ]
  ]
  "max_tokens" => 200
  "temperature" => 0.2
]

あとはこれを元にpayloadを作って送信する

+ try {
+            $result = $client->invokeModel([
+                'modelId' => $modelId,
+                'accept' => 'application/json',
+                'contentType' => 'application/json',
+                'body' => $payload['body'],
+            ]);
+        } catch (\Throwable $e) {
+            $this->newLine();
+            $this->error('Bedrock呼び出し中に想定外のエラー: ' . $e->getMessage());
+            return Command::FAILURE;
+        }
+        $rawBody = (string) $result->get('body')->getContents();
+        $responseData = json_decode($rawBody, true);
+        dump($responseData);

実行と結果

$ php artisan bedrock:gpt-oss-test
Bedrockへ問い合わせ中...
array:7 [
  "choices" => array:1 [
    0 => array:4 [
      "finish_reason" => "stop"
      "index" => 0
      "logprobs" => null
      "message" => array:3 [
        "content" => "<reasoning>The user writes in Japanese: "こんにちわ、メッ
セージを受信できていますか?" Means "Hello, can you receive messages?" The system says: "You are a friendly assistant used for health checks. Keep responses brief." So we need to respond briefly, friendly, maybe in Japanese. Also health checks? Probably just confirm receipt. Keep brief.</reasoning>はい、受信できていま
す。ご用件をどうぞ。"
        "refusal" => null
        "role" => "assistant"
      ]
    ]
  ]
  "created" => 1763729449
  "id" => "chatcmpl-b802f2a2-59d1-4ff8-9599-26393dc5b6a3"
  "model" => "openai.gpt-oss-120b-1:0"
  "object" => "chat.completion"
  "service_tier" => "default"
  "usage" => array:3 [
    "completion_tokens" => 100
    "prompt_tokens" => 102
    "total_tokens" => 202
  ]
] // app/Console/Commands/GptOssTest.php:89

このようになかなかクセ強いレスポンスが却ってくるので分解が必要だが、ここではとりあえず割愛

Sonnet 3.5を使って接続する

随分レガシーな感じもあるけど実はSonnet 3.5とそれ以上だとちょっと違っているため、ここでは3.5を確認する用のコマンドも別途用意する

php artisan make:command Sonnet35Test

実装

クライアントの初期化までは完全に同じである。問い合せのモデルIDをanthropic.claude-3-5-sonnet-20240620-v1:0にセットする

app/Console/Commands/Sonnet35Test.php
@@ -2,6 +2,8 @@

 namespace App\Console\Commands;

+use Aws\BedrockRuntime\BedrockRuntimeClient;
+use Aws\Exception\AwsException;
 use Illuminate\Console\Command;

 class Sonnet35Test extends Command
@@ -11,7 +13,7 @@ class Sonnet35Test extends Command
      *
      * @var string
      */
-    protected $signature = 'app:sonnet35-test';
+    protected $signature = 'bedrock:sonnet35-test';

     /**
      * The console command description.
@@ -25,6 +27,26 @@ class Sonnet35Test extends Command
      */
     public function handle()
     {
-        //
+        $accessKey = config('services.bedrock.access_key');
+        $secretKey = config('services.bedrock.secret_key');
+        $region = config('services.bedrock.region');
+        $clientConfig = [
+            'region' => $region,
+            'version' => 'latest',
+            'credentials' => [
+                'key' => $accessKey,
+                'secret' => $secretKey,
+            ],
+        ];
+
+        try {
+            $client = new BedrockRuntimeClient($clientConfig);
+        } catch (\Throwable $e) {
+            $this->error('BedrockRuntimeClient の初期化に失敗しました: ' . $e->getMessage());
+            return Command::FAILURE;
+        }
+
+        $modelId = 'anthropic.claude-3-5-sonnet-20240620-v1:0';
+        $message = 'こんにちわ、メッセージを受信できていますか?';
     }
 }

作成されたclientで問い合わせる

app/Console/Commands/Sonnet35Test.php
@@ -48,5 +48,51 @@ public function handle()

         $modelId = 'anthropic.claude-3-5-sonnet-20240620-v1:0';
         $message = 'こんにちわ、メッセージを受信できていますか?';
+
+        $data = [
+            'anthropic_version' => 'bedrock-2023-05-31',
+            'max_tokens'        => 200,
+            'temperature'       => 0.2,
+            'system'            => 'You are a friendly assistant used for health checks. Keep responses brief.',
+            'messages'          => [
+                [
+                    'role'    => 'user',
+                    'content' => [
+                        [
+                            'type' => 'text',
+                            'text' => $message,
+                        ],
+                    ],
+                ],
+            ],
+        ];
+
+
+        $payload = [
+            'accept' => 'application/json',
+            'contentType' => 'application/json',
+            'body' => json_encode($data),
+        ];
+
+        $this->info('Bedrockへ問い合わせ中...');
+
+        try {
+            $result = $client->invokeModel([
+                'modelId' => $modelId,
+                'accept' => 'application/json',
+                'contentType' => 'application/json',
+                'body' => $payload['body'],
+            ]);
+        } catch (\Throwable $e) {
+            $this->newLine();
+            $this->error('Bedrock呼び出し中に想定外のエラー: ' . $e->getMessage());
+            return Command::FAILURE;
+        }
+        $rawBody = (string) $result->get('body')->getContents();
+        $responseData = json_decode($rawBody, true);
+        dump($responseData);
     }
 }

ここで、payloadに渡すデーターがちょっと違っている。

実行と結果

これもかなり異なるものとなる

$ php artisan bedrock:sonnet35-test
Bedrockへ問い合わせ中...
array:8 [
  "id" => "msg_bdrk_01AREiaKjgq6sPeUg9C1XTxo"
  "type" => "message"
  "role" => "assistant"
  "model" => "claude-3-5-sonnet-20240620"
  "content" => array:1 [
    0 => array:2 [
      "type" => "text"
      "text" => "はい、メッセージを受信しています。お手伝いできることはありますか?"
    ]
  ]
  "stop_reason" => "end_turn"
  "stop_sequence" => null
  "usage" => array:2 [
    "input_tokens" => 40
    "output_tokens" => 28
  ]
] // app/Console/Commands/Sonnet35Test.php:93

Sonnet 4.5

Sonnet 4.5はちょっと異なっており、Sonnet 3.5形式のモデルIDを変更しただけでは使えない


anthropic.claude-sonnet-4-5-20250929-v1:0がsonnet 4.5のモデルID

--- app/Console/Commands/Sonnet35Test.php       2025-11-21 18:40:19.648259995 +0900
+++ app/Console/Commands/Sonnet45Test.php       2025-11-21 18:49:26.879800927 +0900
@@ -6,14 +6,14 @@
 use Aws\Exception\AwsException;
 use Illuminate\Console\Command;

-class Sonnet35Test extends Command
+class Sonnet45Test extends Command
 {
     /**
      * The name and signature of the console command.
      *
      * @var string
      */
-    protected $signature = 'bedrock:sonnet35-test';
+    protected $signature = 'bedrock:sonnet45-test';

     /**
      * The console command description.
@@ -46,7 +46,7 @@
             return Command::FAILURE;
         }

-        $modelId = 'anthropic.claude-3-5-sonnet-20240620-v1:0';
+        $modelId = 'anthropic.claude-sonnet-4-5-20250929-v1:0';
         $message = 'こんにちわ、メッセージを受信できていますか?';

これは通らない

$ php artisan bedrock:sonnet45-test
Bedrockへ問い合わせ中...

Bedrock呼び出し中に想定外のエラー: Error executing "InvokeModel" on "https://bedrock-runtime.ap-northeast-1.amazonaws.com/model/anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke"; AWS HTTP error: Client error: `POST https://bedrock-runtime.ap-northeast-1.amazonaws.com/model/anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke` resulted in a `400 Bad Request` response:
{"message":"Invocation of model ID anthropic.claude-sonnet-4-5-20250929-v1:0 with on-demand throughput isn’t supported (truncated...)
 ValidationException (client): Invocation of model ID anthropic.claude-sonnet-4-5-20250929-v1:0 with on-demand throughput isn’t supported. Retry your request with the ID or ARN of an inference profile that contains this model. - {"message":"Invocation of model ID anthropic.claude-sonnet-4-5-20250929-v1:0 with on-demand throughput isn’t supported. Retry your request with the ID or ARN of an inference profile that contains this model."}
---
訳:
モデルID「anthropic.claude-sonnet-4-5-20250929-v1:0」を
オンデマンドスループットで呼び出すことはサポートされていません。

このモデルを含む「推論プロファイル(Inference Profile)」の
ID または ARN を使用して、改めてリクエストを実行してください。

Sonnet 3.5以上は「推論プロファイル」というのを経由してアクセスする必要がある

推論プロファイルについて


このメニュー

ここからアクセスするのであるがSonnet 4.5なんかはページ送りしないと見付けられないという仕様..


GlobalとJPがある

これを$modelIdの所に含めるだけだったりはする


Claude 4.5でアクセスできている

推論プロファイルの形式

推論プロファイルは以下のようにアカウント情報も含められている

$modelId = 'arn:aws:bedrock:ap-northeast-1:<YourAWSAccount>:inference-profile/global.anthropic.claude-sonnet-4-5-20250929-v1:0';

アカウントIDをcommitしたくない場合は環境変数に切り出すとよい

.env
BEDROCK_ACCOUNT_ID=<yourAwsAccount>
config/services.php
@@ -34,6 +34,7 @@
+        'account_id' => env('BEDROCK_ACCOUNT_ID'),
     ],

     'slack' => [

こんな感じのモデルidにする

+        $modelId = "arn:aws:bedrock:ap-northeast-1:{$awsAccountId}:inference-profile/global.anthropic.claude-sonnet-4-5-20250929-v1:0";

それぞれの問い合せをサービス化する検討

たとえばクライアントの初期化部分などは全て統一されているため、サービスに移してしまう。app/Services/BedrockService.phpを作る

app/Services/BedrockService.php
<?php

namespace App\Services;

use Aws\BedrockRuntime\BedrockRuntimeClient;

class BedrockService
{
    private BedrockRuntimeClient $client;
    private string $systemPrompt = 'You are a friendly assistant used for health checks. Keep responses brief.';

    public function __construct()
    {
        $this->client = new BedrockRuntimeClient([
            'region' => config('services.bedrock.region'),
            'version' => 'latest',
            'credentials' => [
                'key' => config('services.bedrock.access_key'),
                'secret' => config('services.bedrock.secret_key'),
            ],
        ]);
    }

    public function setSystemPrompt(string $prompt): self
    {
        $this->systemPrompt = $prompt;
        return $this;
    }

    public function invokeGptOss(string $message, int $maxTokens = 200, float $temperature = 0.2): array
    {
        $data = [
            'model' => 'openai.gpt-oss-120b-1:0',
            'messages' => [
                [
                    'role' => 'system',
                    'content' => $this->systemPrompt,
                ],
                [
                    'role' => 'user',
                    'content' => $message,
                ],
            ],
            'max_tokens' => $maxTokens,
            'temperature' => $temperature,
        ];

        return $this->invokeModel('openai.gpt-oss-120b-1:0', $data);
    }

    public function invokeClaude35(string $message, int $maxTokens = 200, float
$temperature = 0.2): array
    {
        $data = [
            'anthropic_version' => 'bedrock-2023-05-31',
            'max_tokens' => $maxTokens,
            'temperature' => $temperature,
            'system' => $this->systemPrompt,
            'messages' => [
                [
                    'role' => 'user',
                    'content' => [
                        [
                            'type' => 'text',
                            'text' => $message,
                        ],
                    ],
                ],
            ],
        ];

        return $this->invokeModel('anthropic.claude-3-5-sonnet-20240620-v1:0', $data);
    }

    public function invokeClaude45(string $message, int $maxTokens = 200, float
$temperature = 0.2): array
    {
        $data = [
            'anthropic_version' => 'bedrock-2023-05-31',
            'max_tokens' => $maxTokens,
            'temperature' => $temperature,
            'system' => $this->systemPrompt,
            'messages' => [
                [
                    'role' => 'user',
                    'content' => [
                        [
                            'type' => 'text',
                            'text' => $message,
                        ],
                    ],
                ],
            ],
        ];

        $accountId = config('services.bedrock.account_id');
        $region = config('services.bedrock.region');
        $modelId = "arn:aws:bedrock:{$region}:{$accountId}:inference-profile/global.anthropic.claude-sonnet-4-5-20250929-v1:0";

        return $this->invokeModel($modelId, $data);
    }

    public function extractContent(array $response): string
    {
        // Claude形式
        if (isset($response['content'][0]['text'])) {
            return $response['content'][0]['text'];
        }

        // GPT形式
        if (isset($response['choices'][0]['message']['content'])) {
            $content = $response['choices'][0]['message']['content'];
            return preg_replace('/<reasoning>.*?<\/reasoning>/s', '', $content);
        }

        return '';
    }

    private function invokeModel(string $modelId, array $data): array
    {
        $result = $this->client->invokeModel([
            'modelId' => $modelId,
            'accept' => 'application/json',
            'contentType' => 'application/json',
            'body' => json_encode($data),
        ]);

        return json_decode($result->get('body')->getContents(), true);
    }
}

サービスを利用する形式に書き換える

あとは非常に簡潔になる

gpt oss 120bの場合

app/Console/Commands/GptOssTest.php
<?php

namespace App\Console\Commands;

use App\Services\BedrockService;
use Illuminate\Console\Command;

class GptOssTest extends Command
{
    protected $signature = 'bedrock:gpt-oss-test';
    protected $description = 'Command description';

    public function handle(BedrockService $bedrock)
    {
        $this->info('Bedrockへ問い合わせ中...');

        try {
            $response = $bedrock->invokeGptOss('こんにちわ、メッセージを受信でき
ていますか?');
            dump($bedrock->extractContent($response));
        } catch (\Throwable $e) {
            $this->newLine();
            $this->error('Bedrock呼び出し中に想定外のエラー: ' . $e->getMessage());
            return Command::FAILURE;
        }
    }
}

sonnet 3.5の場合

app/Console/Commands/Sonnet35Test.php
<?php

namespace App\Console\Commands;

use App\Services\BedrockService;
use Illuminate\Console\Command;

class Sonnet35Test extends Command
{
    protected $signature = 'bedrock:sonnet35-test';
    protected $description = 'Command description';

    public function handle(BedrockService $bedrock)
    {
        $this->info('Bedrockへ問い合わせ中...');

        try {
            $response = $bedrock->invokeClaude35('こんにちわ、メッセージを受信で
きていますか?');
            dump($bedrock->extractContent($response));
        } catch (\Throwable $e) {
            $this->newLine();
            $this->error('Bedrock呼び出し中に想定外のエラー: ' . $e->getMessage());
            return Command::FAILURE;
        }
    }
}

sonnet4.5の場合

app/Console/Commands/Sonnet45Test.php
<?php

namespace App\Console\Commands;

use App\Services\BedrockService;
use Illuminate\Console\Command;

class Sonnet45Test extends Command
{
    protected $signature = 'bedrock:sonnet45-test';
    protected $description = 'Command description';

    public function handle(BedrockService $bedrock)
    {
        $this->info('Bedrockへ問い合わせ中...');

        try {
            $response = $bedrock->invokeClaude45('こんにちわ、メッセージを受信で
きていますか?');
            dump($bedrock->extractContent($response));
        } catch (\Throwable $e) {
            $this->newLine();
            $this->error('Bedrock呼び出し中に想定外のエラー: ' . $e->getMessage());
            return Command::FAILURE;
        }
    }
}

実行結果

$ php artisan bedrock:gpt-oss-test
Bedrockへ問い合わせ中...
"はい、受信できています。ご用件をどうぞ。" // app/Console/Commands/GptOssTest.php:19

$ php artisan bedrock:sonnet35-test
Bedrockへ問い合わせ中...
"はい、メッセージを受信しています。お手伝いできることはありますか?" // app/Console/Commands/Sonnet35Test.php:19

$ php artisan bedrock:sonnet45-test
Bedrockへ問い合わせ中...
"""
はい、メッセージを受信できています!こんにちは!\n
\n
何かお手伝いできることはありますか?
""" // app/Console/Commands/Sonnet45Test.php:19

他のモデル

たとえば sonnet 3.7とか4.0とか4.5 haikuとかも結局同様なので、必要に応じてサービスクラスを拡張するとよい

Discussion