Elixir Livebook から Cloudflare の AI Gateway 経由で Amazon Bedrock を呼んでみる

## はじめに

2024/9/28 大分で Cloudflare Meet-up 開催します！

https://cfm-cts.connpass.com/event/326062/

テーマは「AI Gateway」

というわけで、 Elixir Livebook から AI Gateway を使ってみます

https://livebook.dev/

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/cloudflare/ai_gateway.livemd

## AI Gateway とは

AI Gateway は各種 AI サービスへのプロキシ（中継）として、以下のような機能を提供します

- キャッシュ（同じ質問・指示にはキャッシュで応答）
- レート制限(単位時間あたりのリクエスト数)
- リアルタイムログ取得（リクエストとレスポンスの内容を1時間保持）
- 使用量制限
- 分析
- リクエストフォールバック（一つのAIサービスでエラーが起こったとき、別のAIサービスを使って応答）

https://blog.cloudflare.com/ja-jp/ai-gateway-is-generally-available/

今使える機能は無償利用可能です

今後、ログの永続化などを有償提供するようです

https://developers.cloudflare.com/ai-gateway/pricing/

## セットアップ

新しいノートブックを開き、セットアップセルで以下のコードを実行します

```elixir
Mix.install([
  {:aws, "~> 1.0"},
  {:hackney, "~> 1.20"},
  {:kino, "~> 0.13"},
  {:req, "~> 0.5"}
])
```

直接 Amazon Bedrock を呼び出す場合、 AWS モジュールを使用します

また、 AI Gateway 経由で呼ぶ場合の署名にも利用します

https://github.com/aws-beam/aws-elixir

## 事前準備

### AWS

Amazon Bedrock を使うための準備をしておきます

- Amazon Bedrock で Claude 3.5 のアクセス要求
- Amazon Bedrock を呼び出す権限を付与した IAM ユーザーとその認証情報を作成

手順は以下の記事を参照

https://qiita.com/RyoWakabayashi/items/5680a4bac246a1a0c532

### Cloudflare

Cloudflare のアカウントを作成し、 AI Gateway のゲートウェイを作成しておきます

アカウント作成手順は以下の記事を参照

https://qiita.com/RyoWakabayashi/items/bd9a96290758e11883e8#%E3%82%A2%E3%82%AB%E3%82%A6%E3%83%B3%E3%83%88%E4%BD%9C%E6%88%90%E6%89%8B%E9%A0%86

アカウント作成後、ホーム画面で左メニューから AI を開き、 AI Gateway をクリック

![スクリーンショット 2024-08-28 9.53.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/506a777d-feaf-49bf-7c70-b342292c89c1.png)

「ゲートウェイの作成」をクリックして開いたモーダルにゲートウェイの名前を入力
（本記事ではゲートウェイ名を「bedrock」に指定）

![スクリーンショット 2024-08-28 9.54.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f6395dda-0a3d-5d7b-c90a-47087d092142.png)

ゲートウェイが作成されるので、右上（赤枠）の API ボタンをクリック

![スクリーンショット 2024-08-28 9.55.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/b02487f1-7914-c0b7-aa39-406413eba244.png)

開いたモーダルに AI Gateway のエンドポイントが表示されます

![スクリーンショット 2024-08-28 9.55.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/d6c6e7f0-bb8a-34bb-567b-13b289eabee4.png)

エンドポイントは以下のような構成になっています

`https://gateway.ai.cloudflare.com/v1/<Cloudflare のアカウントID>/<ゲートウェイ名>/`

「Cloudflare のアカウントID」と「ゲートウェイ名」を後の手順で使用します

## 認証情報の設定

テキスト入力を作成し、 AWS の認証情報とリージョン、 Cloudflare で用意しておいたアカウント ID、ゲートウェイ名を入力します

リージョンはアクセス要求を出したリージョンを指定します

```elixir
aws_access_key_id_input = Kino.Input.password("AWS ACCESS_KEY_ID")
aws_secret_access_key_input = Kino.Input.password("AWS SECRET_ACCESS_KEY")
aws_region_input = Kino.Input.text("AWS REGION")

cf_account_id_input = Kino.Input.password("CLOUDFLARE ACCOUNT_ID")
cf_gateway_name_input = Kino.Input.text("CLOUDFLARE GATEWAY_NAME")

[
  aws_access_key_id_input,
  aws_secret_access_key_input,
  aws_region_input,
  cf_account_id_input,
  cf_gateway_name_input
]
|> Kino.Layout.grid(columns: 3)
```

![スクリーンショット 2024-08-28 15.45.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/da717369-7d1a-329b-5d0c-67d556b39289.png)

認証情報を利用して、 AWS にアクセスするためのクライアントを作成します

```elixir
aws_client =
  AWS.Client.create(
    Kino.Input.read(aws_access_key_id_input),
    Kino.Input.read(aws_secret_access_key_input),
    Kino.Input.read(aws_region_input)
  )
```

## AWS クライアントからの Bedrock 呼び出し

まずは通常通り、 AWS クライアントから Bedrock を呼び出してみます

やっていることは以下の記事と同じです

https://qiita.com/RyoWakabayashi/items/5029893913ff37d58051

モデルIDで Claude 3.5 Sonnet を指定します

```elixir
model_id = "anthropic.claude-3-5-sonnet-20240620-v1:0"
```

生成AIへの指示を用意します

```elixir
input = "あなたの名前を教えてください。"

payload = %{
  "messages" => [%{
    "role" => "user",
    "content" => [%{"text" => input}]
  }]
}
```

返答に少し時間がかかるため、リクエストのタイムアウトに 60 秒を指定します

```elixir
options = [recv_timeout: 60_000]
```

`AWS.BedrockRuntime.converse` で Claude 3.5 にリクエストを送ります

```elixir
result =
  aws_client
  |> AWS.BedrockRuntime.converse(model_id, payload, options)
  |> elem(1)
```

レスポンスは以下のようになります

```elixir
%{
  "metrics" => %{"latencyMs" => 1095},
  "output" => %{
    "message" => %{
      "content" => [
        %{
          "text" => "私の名前はClaudeです。AIアシスタントとして作られました。どうぞよろしくお願いします。"
        }
      ],
      "role" => "assistant"
    }
  },
  "stopReason" => "end_turn",
  "usage" => %{"inputTokens" => 19, "outputTokens" => 39, "totalTokens" => 58}
}
```

Livebook ではレスポンスからテキストを取り出し、マークダウンとして表示できます

```elixir
result
|> Map.get("output")
|> Map.get("message")
|> Map.get("content")
|> hd()
|> Map.get("text")
|> Kino.Markdown.new()
```

実行結果

![スクリーンショット 2024-08-28 16.33.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/b84d671d-b6d8-8f85-8cd8-c5ee6633921b.png)

ここまでは通常通りの Bedrock の使い方です

## AWS.Client.request による Bedrock の呼び出し

AI Gateway から Amazon Bedrock を呼び出す場合、リクエストへの署名が必要です

AWS モジュールでは内部的に署名と AWS のAPI 呼び出しを実施してくれています

まずはその実装を参考に署名と AWS API 呼び出しを実行してみましょう

まず、 API 呼び出しのヘッダーと URL を用意します

```elixir
aws_region = Kino.Input.read(aws_region_input)

host = "bedrock-runtime.#{aws_region}.amazonaws.com"

headers = [
  {"Host", host},
  {"Content-Type", "application/json"}
]

encoded_path = "/model/#{AWS.Util.encode_uri(model_id)}/converse"

url = "https://#{host}#{encoded_path}"
```

:::note warn
AWS モジュールの実装では "Content-Type" を "application/x-amz-json-1.1" に指定していますが、この指定だと AI Gateway のログ上にリクエスト内容が表示されません

https://github.com/aws-beam/aws-elixir/blob/master/lib/aws/generated/bedrock_runtime.ex#L886

"Content-Type" は "application/json" を指定します

Bedrock 自体はどちらでも応答してくれます
:::

URL は以下のような値になります

```elixir
"https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-5-sonnet-20240620-v1%3A0/converse"
```

`AWS.Util.encode_uri` を通すことで `:` が `%3A` になっていることが肝ですね

署名に必要な現在時刻を取得します

```elixir
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
```

実行結果(例)

```elixir
~N[2024-08-28 04:53:09]
```

署名には有効期間があるため、この時刻から一定時間経過すると使えなくなります（エラーメッセージに `Signature expired` が返ってくる）

リクエスト内容を JSON 文字列に変換します

```elixir
encoded_payload = AWS.Client.encode!(aws_client, payload, :json)
```

実行結果

```elixir
"{\"messages\":[{\"content\":[{\"text\":\"あなたの名前を教えてください。\"}],\"role\":\"user\"}]}"
```

`AWS.Signature.sign_v4` 関数を利用して署名付ヘッダーを生成します

```elixir
siged_headers =
  AWS.Signature.sign_v4(
    %{aws_client | service: "bedrock"}, 
    now,
    :post,
    url,
    headers,
    encoded_payload
  )
```

実行結果

```elixir
[
  {"Authorization",
   "AWS4-HMAC-SHA256 Credential=xxx/20240828/us-east-1/bedrock/aws4_request,SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date,Signature=999"},
  {"X-Amz-Content-SHA256", "aaa"},
  {"X-Amz-Date", "20240828T045309Z"},
  {"Host", "bedrock-runtime.us-east-1.amazonaws.com"},
  {"Content-Type", "application/json"}
]
```

署名付ヘッダーを指定して AWS API を呼び出します

```elixir
AWS.Client.request(aws_client, :post, url, encoded_payload, siged_headers, options)
```

実行結果

```elixir
{:ok,
 %{
   body: "{\"metrics\":{\"latencyMs\":706},\"output\":{\"message\":{\"content\":[{\"text\":\"私の名前はClaudeです。よろしくお願いします。\"}],\"role\":\"assistant\"}},\"stopReason\":\"end_turn\",\"usage\":{\"inputTokens\":19,\"outputTokens\":23,\"totalTokens\":42}}",
   headers: [
     {"Date", "Wed, 28 Aug 2024 07:40:18 GMT"},
     {"Content-Type", "application/json"},
     {"Content-Length", "244"},
     {"Connection", "keep-alive"},
     {"x-amzn-RequestId", "07b47719-ae2d-4822-93cd-158d2a9c7853"}
   ],
   status_code: 200
 }}
```

## AI Gateway 経由で Amazon Bedrock を呼び出す

AI Gateway のエンドポイント URL を組み立てます

```elixir
cf_account_id = Kino.Input.read(cf_account_id_input)
cf_gateway_name = Kino.Input.read(cf_gateway_name_input)
cf_host = "gateway.ai.cloudflare.com"

gw_url =
  "https://#{cf_host}/v1/#{cf_account_id}/#{cf_gateway_name}/aws-bedrock/bedrock-runtime/#{aws_region}#{encoded_path}"
```

署名付ヘッダーのうち、 "Host" の値を AI Gateway のホストに変換します

```elixir
gw_header =
  siged_headers
  |> Enum.map(fn {key, value} ->
    if key == "Host" do
      {key, cf_host}
    else
      {key, value}
    end
  end)
```

実行結果

```elixir
[
  {"Authorization",
   "AWS4-HMAC-SHA256 Credential=xxx/20240828/us-east-1/bedrock/aws4_request,SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date,Signature=999"},
  {"X-Amz-Content-SHA256", "aaa"},
  {"X-Amz-Date", "20240828T045309Z"},
  {"Host", "gateway.ai.cloudflare.com"},
  {"Content-Type", "application/json"}
]
```

`Req` を使って、普通の REST API のようにリクエストを投げます

```elixir
result =
  Req.new(url: gw_url, headers: gw_header, body: encoded_payload)
  |> Req.post!(connect_options: [timeout: 60_000])
  |> Map.get(:body)
```

実行結果

```elixir
%{
  "metrics" => %{"latencyMs" => 1008},
  "output" => %{
    "message" => %{
      "content" => [
        %{
          "text" => "私の名前はClaudeです。人工知能アシスタントとして作られました。よろしくお願いします。"
        }
      ],
      "role" => "assistant"
    }
  },
  "stopReason" => "end_turn",
  "usage" => %{"inputTokens" => 19, "outputTokens" => 38, "totalTokens" => 57}
}
```

無事、 AI Gateway 経由で呼び出すことができました

AI Gateway のコンソールを見ると、リスエストとレスポンスのログが取得できています

![スクリーンショット 2024-08-28 16.49.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/64ee20fb-c96e-8e13-d87d-320579d2979f.png)

## モジュール化

ここまでの処理を整理し、 AI Gateway 経由で Bedrock を呼び出すためのモジュールを定義しましょう

```elixir
defmodule AIGateway do
  @cf_host "gateway.ai.cloudflare.com"
  
  def invoke(aws_client, model_id, cf_account_id, cf_gateway_name, input) do
    payload =
      %{
        "messages" => [%{
          "role" => "user",
          "content" => [%{"text" => input}]
        }]
      }

    host = "bedrock-runtime.#{aws_client.region}.amazonaws.com"
    
    headers = [
      {"Host", host},
      {"Content-Type", "application/json"}
    ]
    
    encoded_path = "/model/#{AWS.Util.encode_uri(model_id)}/converse"
    encoded_payload = AWS.Client.encode!(aws_client, payload, :json)

    gw_headers = sign_headers(
      aws_client,
      "https://#{host}#{encoded_path}",
      headers,
      encoded_payload
    )
    
    gw_url =
      "https://#{@cf_host}/v1/#{cf_account_id}/#{cf_gateway_name}/aws-bedrock/bedrock-runtime/#{aws_client.region}#{encoded_path}"

    Req.new(url: gw_url, headers: gw_headers, body: encoded_payload)
    |> Req.post!(connect_options: [timeout: 60_000])
    |> Map.get(:body)
  end

  defp sign_headers(aws_client, url, headers, payload) do
    now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)

    %{aws_client | service: "bedrock"}
    |> AWS.Signature.sign_v4(
      now,
      :post,
      url,
      headers,
      payload
    )
    |> Enum.map(fn {key, value} ->
      if key == "Host" do
        {key, @cf_host}
      else
        {key, value}
      end
    end)
  end
end
```

## キャッシュの確認

キャッシュ機能を試してみたいので、 AI Gateway でキャッシュを有効にします

AI Gateway の画面から「設定」タブを開き、「キャッシュ　レスポンス」のトグルを ON にしてください

![スクリーンショット 2024-08-28 17.05.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/78ae0698-c4e3-6fdb-a9fd-aaada34b223b.png)

Livebook で任意の質問・指示ができるように、テキストエリアを用意します

```elixir
instruction_input = Kino.Input.textarea("INSTRUCTION")
```

![スクリーンショット 2024-08-28 16.53.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/232a2299-f305-be7e-4082-7dd622d39fd7.png)

定義したモジュールを使って AI Gateway を呼び出し、結果を表示します

```elixir
instruction = Kino.Input.read(instruction_input)

aws_client
|> AIGateway.invoke(model_id, cf_account_id, cf_gateway_name, instruction)
|> Map.get("output")
|> Map.get("message")
|> Map.get("content")
|> hd()
|> Map.get("text")
|> Kino.Markdown.new()
```

実行結果

![スクリーンショット 2024-08-28 16.54.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1dd82f99-1d4a-f8f6-527e-ff00e1e0d136.png)

約8秒程でちゃんと答えが返ってきました

:::note info
正確な源泉数や湧出量はこちらを参照してください

https://www.city.beppu.oita.jp/sangyou/onsen/detail4.html
:::

このまま質問を変えずにもう一度セルを実行すると、今度は1秒未満でなじ結果が返ってきます

AI Gateway の画面で確認すると、キャッシュで返答したことが分かります

![スクリーンショット 2024-08-28 17.52.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f7773f1a-2e6f-0576-31e2-4c7a981b0b76.png)

## レート制限

「設定」タブのレート制限から、１分間に1回のレート制限を設定してみます

![スクリーンショット 2024-08-28 17.56.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/ba4ed8f2-9fe2-4fea-3455-fdfc6a23eddf.png)

この状態で立て続けに呼び出すと、 "Rate limited" というメッセージが返ってきます

![スクリーンショット 2024-08-28 18.00.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/28b19730-0c89-c7e2-0816-c827f383f2a8.png)

AI Gateway のログ上でもエラーになっていることが確認できます

![スクリーンショット 2024-08-28 18.01.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/824b5f48-e104-4966-d9cb-cb76d29423f0.png)

## 分析

AI Gayeway の「分析」タグを開くと、使用状況がグラフ化されているのが確認できます

![スクリーンショット 2024-08-28 18.04.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/42a2504d-8602-eb08-7d47-c2b73a5133d7.png)

## API からのログ取得

Cloudflare API から AI Gateway のログを取得できます

### API トークンの作成

Cloudflare の右上アイコンをクリックし、表示されるドロップダウンから「マイプロフィール」を開きます

![スクリーンショット 2024-08-28 18.12.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c18c584c-2079-896b-bc05-026f832f0471.png)

左メニューの「API トークン」を開き、「トークンを作成する」をクリックします

![スクリーンショット 2024-08-28 18.13.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/94b3d238-954c-b0e0-d4b3-70f438619a1b.png)

遷移した画面の一番下「カスタムトークンを作成する」の右にある「始める」をクリックします

![スクリーンショット 2024-08-28 18.15.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/3408a0ce-6be8-575e-8b7f-72106c2c1608.png)

トークン名を適当な値にして、アクセス許可で「AI Gateway」の「読み取り」を選択します

![スクリーンショット 2024-08-28 18.15.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/d87a07c0-dbcd-c8a2-f07f-c32ea3b37c25.png)

画面一番下の「概要に進む」をクリックします

![スクリーンショット 2024-08-28 18.18.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/1dffd212-c7b5-d752-5357-1a25c2dcd169.png)

内容を確認し、「トークンを作成する」をクリックします

![スクリーンショット 2024-08-28 18.17.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/a0b2a029-8dee-c7af-e1b5-0696ce7334b4.png)

トークンの値が表示されるので、この値を Livebook で使用します

### API の呼び出し

Livebook にトークン用の入力を作成し、トークンの値を設定します

```elixir
cf_token_input = Kino.Input.password("CLOUDFLARE TOKEN")
```

![スクリーンショット 2024-08-28 18.21.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/0bf65535-c1cc-6967-11fd-b9cb89e6f960.png)

トークンを認証ヘッダーに設定し、 API を呼び出します

```elixir
cf_api_url =
  "https://api.cloudflare.com/client/v4/accounts/#{cf_account_id}/ai-gateway/gateways/#{cf_gateway_name}/logs"

cf_token = Kino.Input.read(cf_token_input)

cf_api_headers = [
  {"Authorization", "Bearer #{cf_token}"},
  {"Content-Type", "application/json"}
]

result =
  Req.new(url: cf_api_url, headers: cf_api_headers)
  |> Req.get!()
  |> Map.get(:body)
```

実行結果

```elixir
%{
  "result" => [
    %{
      "cached" => false,
      "cost" => 0,
      "created_at" => "2024-08-28 09:05:19",
      "duration" => 8346,
      "id" => "01J6C3PX50TTFCQDE9NCXJDCFS",
      "metadata" => "",
      "model" => "",
      "path" => "bedrock-runtime/us-east-1/model/anthropic.claude-3-5-sonnet-20240620-v1%3A0/converse",
      "provider" => "aws-bedrock",
      "request" => "{\"messages\":[{\"content\":[{\"text\":\"「やせうま」とはどういう食べ物ですか\"}],\"role\":\"user\"}]}",
      "request_content_type" => "application/json",
      "request_type" => "provider",
      "response" => "{\"metrics\":{\"latencyMs\":7112},\"output\":{\"message\":{\"content\":[{\"text\":\"「やせうま」は、日本の伝統的な菓子の一種です。以下に「やせうま」の特徴をまとめます：\\n\\n1. 形状：細長い棒状",
      "response_content_type" => "application/json",
      "status_code" => 200,
      "step" => 0,
      "success" => true,
      "tokens_in" => 0,
      "tokens_out" => 0
    },
    ...
  ],
  "result_info" => %{"count" => 19, "page" => 1, "per_page" => 20, "total_count" => 19},
  "success" => true
}
```

リクエストやレスポンスの内容、時間、実行結果、キャッシュなどの情報が取得できました

## まとめ

AI Gateway を使うことで、 Bedrock のログ取得やキャッシュ設定、レート制限などが簡単に実装できました

生成AIの利用実績を分析したい場合などには有効そうです