1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

KICでAI Proxy Pluginを試してみた

1
Posted at

はじめに

前回の記事では、Kong Ingress Controller (KIC) を使ってOSS版Kong GatewayをKubernetes上に構築しました。
今回は、Kong Gatewayの機能の一つである AI Proxy Plugin を使用して、複数のLLM(Large Language Model)プロバイダーへの統一的なアクセスポイントを構築する方法を紹介します。

動機

Kong Solution Day 2026 Winterの登壇内容に、AI Gatewayの重要性について発表するものがありました。

生成AIの利用拡大に伴い、支出コストはどんどん上昇しています。
米国では、AI Gatewayの導入によりセキュリティ・可観測性などを担保しつつ、支出コストの削減を達成する企業が現れ始めているようです。
例えば、簡単な作業はより安価なLLMに任せ、難しい作業は最新モデルに実施させることで、支出コストを削減できます。AI Gatewayがその振り分けを担います。

Kong AI Gatewayを採用することで、LLMの利用にかかる支出コストを削減することができます。
また、他にも様々なメリットを享受できるとのことでした。

項目 内容
複数のAIツールの導入をシンプルにします 共通のAIインターフェースを通じて、複数のLLMの実装・アクセスを簡単に切り替えることができます
機密データを保護します ・LLMに対する情報の入出力を制御するポリシーを設定し、プロンプト攻撃や機密データの流出を防ぎます
・LLMへのアクセスで利用するAPIキーの一元管理が可能となります
社内のAI使用を適切に統制します ・AIの利用を監視し、すべてのプロバイダやモデルに利用ポリシーを適用して、責任あるAI利用を社内で徹底します
・LLMの利用に独自のレート制限を制限を設定し、支出コストの管理を強化します

Kong AI Gatewayの詳細は、以下のドキュメントを参照して下さい。

AI Proxy Pluginとは

AI Proxy Pluginは、Kong GatewayをAIモデルへのプロキシとして機能させるプラグインです。
OpenAI、Anthropic (Claude)、Google (Gemini)など、主要なAIプロバイダーに対応しています。

このプラグインを利用することで、以下のメリットが得られます。

項目 内容
APIキーの一元管理 クライアントアプリにAPIキーを持たせず、Gateway側で安全に管理できます。Vaultのようなキー管理システムとの連携も可能です。
インターフェースの統一 各プロバイダーで異なるAPI仕様を、Kongが吸収して統一フォーマット(OpenAI互換など)でクライアントに提供できます
ガバナンスと可観測性 AIへのリクエストを一箇所でログ記録、監視、レート制限できます

実行環境

前回の記事で構築したKIC環境(Gateway APIモード)を引き続き使用します。
Kong Gatewayはバージョン3.7以上を利用しましょう。

 Kubernetes: v1.33.1 (OrbStack)
 Kong Gateway: 3.9
 Kong Ingress Controller: 3.5

実装の概要

Kong Gatewayが /ai/* を公開し続けると、誰でもAPIキーを使ってLLMを実行できてしまいます。
検証が終わったら、作成したリソースは必ず削除して下さい。

今回は、以下の3つのAIモデルを利用できるチャットAPIを構築します。

  1. Google Gemini (Gemini 2.5 Flash)
  2. Anthropic Claude (Claude 3.5 Haiku)
  3. Sakura AI (さくらのクラウド 高火力, OpenAI互換)

クライアント(チャットUI)はKong Gateway (/ai/{provider}) にリクエストを送り、KongがそれぞれのAIプロバイダーに認証情報を付与して転送します。

APIキーの発行

各社のAPIキーは発行済みの前提で進めます。

実装手順

1. APIキーの管理 (Kubernetes Secret)

まず、各AIプロバイダーのAPIキーをKubernetesのSecretとして登録します。
本番環境ではHashiCorp Vaultなどの外部Secret管理システムとの連携が推奨されますが、今回はシンプルにKubernetes Secretを使用します。

kubectl create secret generic ai-keys -n kong \
  --from-literal=GEMINI_API_KEY="<YOUR_GEMINI_API_KEY>" \
  --from-literal=ANTHROPIC_API_KEY="<YOUR_ANTHROPIC_API_KEY>" \
  --from-literal=SAKURA_API_KEY="Bearer ${YOUR_SAKURA_API_KEY}"

Kong GatewayがこのSecretを参照できるように、values.yaml で環境変数としてPodに注入する設定を行います。

values.yaml
gateway:
  env:
    ANTHROPIC_API_KEY:
      valueFrom:
        secretKeyRef:
          name: ai-keys
          key: ANTHROPIC_API_KEY
    GEMINI_API_KEY:
      valueFrom:
        secretKeyRef:
          name: ai-keys
          key: GEMINI_API_KEY
    SAKURA_API_KEY:
      valueFrom:
        secretKeyRef:
          name: ai-keys
          key: SAKURA_API_KEY

HelmChartを更新します。

helm upgrade --install kong kong/ingress -n kong -f values.yml

2. KongPluginリソースの定義

KICでは、KongPlugin カスタムリソースを使ってプラグインの設定を定義します。
各プロバイダーごとに ai-proxy プラグインの設定を作成します。
ここでは、環境変数からAPIキーを読み込むために {vault://env/VAR_NAME} 記法を使用しています。

Chat UIデプロイ先のNamespace ai-gateway を作成します。

kubectl apply -f - <<EOF
apiVersion: v1
kind: Namespace
metadata:
  name: ai-gateway
  labels:
    shared-gateway-access: "true"
EOF

Google Gemini用設定

Secretから注入した環境変数名には KONG_ のプレフィックスが付与される点に注意して下さい。

参考:https://developer.konghq.com/ai-gateway/ai-providers/gemini/

apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
  name: ai-proxy-gemini
  namespace: ai-gateway
plugin: ai-proxy
config:
  route_type: "llm/v1/chat"
  auth:
    param_name: key
    param_value: "{vault://env/KONG_GEMINI_API_KEY}"
    param_location: query
  model:
    provider: gemini
    name: "gemini-2.5-flash"
    options:
      max_tokens: 512
      temperature: 1.0

Anthropic Claude用設定

Claudeの設定では、CORS対策としてブラウザからの直接アクセス許可ヘッダーを追加するため、request-transformer プラグインを併用します(後述のRoute設定で使用します)。

参考:https://developer.konghq.com/ai-gateway/ai-providers/anthropic/

apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
  name: ai-proxy-claude
  namespace: ai-gateway
plugin: ai-proxy
config:
  route_type: "llm/v1/chat"
  auth:
    header_name: "x-api-key"
    header_value: "{vault://env/KONG_ANTHROPIC_API_KEY}"
  model:
    provider: anthropic
    # https://platform.claude.com/docs/en/about-claude/models/overview
    name: "claude-haiku-4-5-20251001"
    options:
      # https://platform.claude.com/docs/en/api/versioning
      anthropic_version: "2023-06-01"
      max_tokens: 512
      temperature: 1.0
---
# CORS対策用ヘッダー追加プラグイン
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
  name: add-anthropic-header
  namespace: ai-gateway
plugin: request-transformer
config:
  add:
    headers:
      - "anthropic-dangerous-direct-browser-access: true"

anthropic-dangerous-direct-browser-access ヘッダーについては、以下の記事が参考になりました。

さくらのAIエンジン用設定 (OpenAI互換)

さくらのAIエンジンの設定値は、各社の設定に準拠すれば基本OKなはず、です。

参考:https://developer.konghq.com/ai-gateway/ai-providers/openai/

apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
  name: ai-proxy-sakura
  namespace: ai-gateway
plugin: ai-proxy
config:
  route_type: "llm/v1/chat"
  auth:
    header_name: "Authorization"
    # Secret作成時に Bearer を含めています
    header_value: "{vault://env/KONG_SAKURA_API_KEY}"
  model:
    provider: openai
    name: "llm-jp-3.1-8x13b-instruct4"
    options:
      upstream_url: "https://api.ai.sakura.ad.jp/v1/chat/completions"
      max_tokens: 512
      temperature: 0.1

3. HTTPRouteの定義

最後に、作成したプラグインをルートに適用します。
backendRefs にはダミーのサービス(ここでは chat-ui)を指定していますが、実際のリクエストは ai-proxy プラグインによってインターセプトされ、AIプロバイダーへ転送されます。

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: ai-routes
  namespace: ai-gateway
spec:
  parentRefs:
  - name: kong
    namespace: kong
  rules:
  # Gemini用のルーティング
  - matches:
    - path:
        type: PathPrefix
        value: /ai/gemini
    filters:
    - type: ExtensionRef
      extensionRef:
        group: configuration.konghq.com
        kind: KongPlugin
        name: ai-proxy-gemini
    backendRefs:
    - name: chat-ui
      port: 80

  # Claude用のルーティング
  - matches:
    - path:
        type: PathPrefix
        value: /ai/claude
    filters:
    - type: ExtensionRef
      extensionRef:
        group: configuration.konghq.com
        kind: KongPlugin
        name: ai-proxy-claude
    - type: ExtensionRef
      extensionRef:
        group: configuration.konghq.com
        kind: KongPlugin
        name: add-anthropic-header
    backendRefs:
    - name: chat-ui
      port: 80

  # さくらのAIエンジン用のルーティング
  - matches:
    - path:
        type: PathPrefix
        value: /ai/sakura
    filters:
    - type: ExtensionRef
      extensionRef:
        group: configuration.konghq.com
        kind: KongPlugin
        name: ai-proxy-sakura
    backendRefs:
    - name: chat-ui
      port: 80

4. Chat UIのデプロイ

即席のChat UIをNginxで公開します。このアプリケーションからKong Gatewayを介してLLMにアクセスします。

Chat UIのマニフェスト
apiVersion: v1
kind: ConfigMap
metadata:
  name: chat-ui-code
  namespace: ai-gateway
data:
  index.html: |
    <!DOCTYPE html>
    <html lang="ja">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Kong AI Gateway Demo</title>
      <style>
        :root {
          /* Digital Agency Design System inspired colors */
          --color-primary: #005CA2;
          --color-primary-hover: #004070;
          --color-background: #F5F7FA;
          --color-surface: #FFFFFF;
          --color-text-main: #1A1A1A;
          --color-text-sub: #666666;
          --color-border: #D1D5DB;
          --color-input-bg: #FFFFFF;
          --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
          --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
          --radius-md: 8px;
          --radius-lg: 12px;
          --font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Hiragino Sans", "Noto Sans JP", sans-serif;
        }

        * { box-sizing: border-box; }

        body {
          font-family: var(--font-family);
          background-color: var(--color-background);
          color: var(--color-text-main);
          margin: 0;
          padding: 0;
          display: flex;
          flex-direction: column;
          height: 100vh;
        }

        /* Header */
        header {
          background-color: var(--color-surface);
          border-bottom: 1px solid var(--color-border);
          padding: 1rem 1.5rem;
          display: flex;
          align-items: center;
          justify-content: space-between;
          box-shadow: var(--shadow-sm);
          z-index: 10;
        }

        h1 {
          font-size: 1.25rem;
          font-weight: 700;
          color: var(--color-primary);
          margin: 0;
        }

        /* Model Selector */
        .model-select-wrapper {
          position: relative;
        }

        select {
          appearance: none;
          background-color: var(--color-surface);
          border: 1px solid var(--color-border);
          border-radius: var(--radius-md);
          padding: 0.5rem 2rem 0.5rem 1rem;
          font-size: 0.9rem;
          color: var(--color-text-main);
          cursor: pointer;
          transition: border-color 0.2s, box-shadow 0.2s;
        }

        select:focus {
          outline: none;
          border-color: var(--color-primary);
          box-shadow: 0 0 0 2px rgba(0, 92, 162, 0.2);
        }

        /* Chat Area */
        .chat-container {
          flex: 1;
          overflow-y: auto;
          padding: 2rem;
          display: flex;
          flex-direction: column;
          gap: 1.5rem;
          scroll-behavior: smooth;
        }

        .message {
          display: flex;
          flex-direction: column;
          max-width: 80%;
          animation: fadeIn 0.3s ease-out;
        }

        @keyframes fadeIn {
          from { opacity: 0; transform: translateY(10px); }
          to { opacity: 1; transform: translateY(0); }
        }

        .message.user {
          align-self: flex-end;
          align-items: flex-end;
        }

        .message.ai {
          align-self: flex-start;
          align-items: flex-start;
        }

        .message-sender {
          font-size: 0.75rem;
          color: var(--color-text-sub);
          margin-bottom: 0.25rem;
          padding: 0 0.5rem;
        }

        .message-content {
          padding: 1rem 1.25rem;
          border-radius: var(--radius-lg);
          line-height: 1.6;
          font-size: 1rem;
          position: relative;
          word-break: break-word;
          box-shadow: var(--shadow-sm);
        }

        .message.user .message-content {
          background-color: var(--color-primary);
          color: white;
          border-bottom-right-radius: 2px;
        }

        .message.ai .message-content {
          background-color: var(--color-surface);
          color: var(--color-text-main);
          border: 1px solid var(--color-border);
          border-bottom-left-radius: 2px;
        }

        /* Typing Indicator */
        .typing-indicator {
          display: flex;
          gap: 4px;
          padding: 0.5rem 0;
        }

        .typing-dot {
          width: 6px;
          height: 6px;
          background-color: var(--color-text-sub);
          border-radius: 50%;
          animation: bounce 1.4s infinite ease-in-out both;
        }

        .typing-dot:nth-child(1) { animation-delay: -0.32s; }
        .typing-dot:nth-child(2) { animation-delay: -0.16s; }

        @keyframes bounce {
          0%, 80%, 100% { transform: scale(0); }
          40% { transform: scale(1); }
        }

        /* Footer Input Area */
        footer {
          background-color: var(--color-surface);
          border-top: 1px solid var(--color-border);
          padding: 1.5rem;
          box-shadow: 0 -2px 10px rgba(0,0,0,0.03);
        }

        .input-group {
          max-width: 900px;
          margin: 0 auto;
          display: flex;
          gap: 1rem;
          position: relative;
        }

        input[type="text"] {
          flex: 1;
          padding: 1rem 1.25rem;
          border: 1px solid var(--color-border);
          border-radius: var(--radius-md);
          font-size: 1rem;
          transition: all 0.2s;
          background-color: var(--color-input-bg);
        }

        input[type="text"]:focus {
          outline: none;
          border-color: var(--color-primary);
          box-shadow: 0 0 0 3px rgba(0, 92, 162, 0.1);
        }

        button {
          background-color: var(--color-primary);
          color: white;
          border: none;
          border-radius: var(--radius-md);
          padding: 0 1.5rem;
          font-weight: 600;
          font-size: 1rem;
          cursor: pointer;
          transition: background-color 0.2s;
          display: flex;
          align-items: center;
          gap: 0.5rem;
        }

        button:hover {
          background-color: var(--color-primary-hover);
        }

        button:disabled {
          background-color: var(--color-border);
          cursor: not-allowed;
        }

        /* Responsive */
        @media (max-width: 600px) {
          .message { max-width: 90%; }
          footer { padding: 1rem; }
          .input-group { flex-direction: column; }
          button { width: 100%; padding: 0.8rem; justify-content: center; }
        }
      </style>
    </head>
    <body>
      <header>
        <h1>Kong AI Gateway</h1>
        <div class="model-select-wrapper">
          <select id="modelSelect">
            <option value="/ai/claude">Claude</option>
            <option value="/ai/gemini">Google Gemini</option>
            <option value="/ai/sakura">Sakura AI</option>
          </select>
        </div>
      </header>

      <div class="chat-container" id="chatBox">
        <div class="message ai">
          <div class="message-sender">AI Assistant</div>
          <div class="message-content">
            こんにちは!Kong AI Gatewayへようこそ。<br>
            モデルを選択して、メッセージを入力してください。
          </div>
        </div>
      </div>

      <footer>
        <div class="input-group">
          <input type="text" id="userInput" placeholder="メッセージを入力..." onkeypress="handleEnter(event)">
          <button id="sendBtn" onclick="sendMessage()">送信</button>
        </div>
      </footer>

      <script>
        const chatBox = document.getElementById('chatBox');
        const userInput = document.getElementById('userInput');
        const sendBtn = document.getElementById('sendBtn');
        
        function handleEnter(e) {
          if (e.key === 'Enter' && !sendBtn.disabled) sendMessage();
        }

        async function sendMessage() {
          const message = userInput.value.trim();
          if (!message) return;

          const endpoint = document.getElementById('modelSelect').value;
          
          // UI Update
          appendMessage('User', message, 'user');
          userInput.value = '';
          setLoading(true);

          try {
            // AI Placeholder with Loading Animation
            const loadingId = appendLoadingMessage();

            const response = await fetch(endpoint, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({
                messages: [{ role: "user", content: message }]
              })
            });

            if (!response.ok) {
              throw new Error(`API Error: ${response.status}`);
            }

            const data = await response.json();
            const aiText = extractContent(data);
            
            updateMessage(loadingId, aiText);
          } catch (error) {
            appendMessage('System', 'エラーが発生しました: ' + error.message, 'ai');
          } finally {
            setLoading(false);
          }
        }

        function extractContent(data) {
          // Handle different provider response formats
          if (data.choices && data.choices[0] && data.choices[0].message) {
            return data.choices[0].message.content; // OpenAI format
          }
          if (data.content && data.content[0] && data.content[0].text) {
             return data.content[0].text; // Anthropic format sometimes
          }
          if (data.content) return data.content; // Simple content
          return JSON.stringify(data, null, 2); // Fallback
        }

        function appendMessage(sender, text, className) {
          const div = document.createElement('div');
          div.className = `message ${className}`;
          div.innerHTML = `
            <div class="message-sender">${sender}</div>
            <div class="message-content">${formatText(text)}</div>
          `;
          chatBox.appendChild(div);
          scrollToBottom();
          return null;
        }

        function appendLoadingMessage() {
          const id = 'msg-' + Date.now();
          const div = document.createElement('div');
          div.className = 'message ai';
          div.id = id;
          div.innerHTML = `
            <div class="message-sender">AI</div>
            <div class="message-content">
              <div class="typing-indicator">
                <div class="typing-dot"></div>
                <div class="typing-dot"></div>
                <div class="typing-dot"></div>
              </div>
            </div>
          `;
          chatBox.appendChild(div);
          scrollToBottom();
          return id;
        }

        function updateMessage(id, text) {
          const div = document.getElementById(id);
          if (div) {
            const contentDiv = div.querySelector('.message-content');
            contentDiv.innerHTML = formatText(text);
          }
        }

        function setLoading(isLoading) {
          userInput.disabled = isLoading;
          sendBtn.disabled = isLoading;
          sendBtn.innerText = isLoading ? '送信中...' : '送信';
          if (!isLoading) userInput.focus();
        }

        function scrollToBottom() {
          chatBox.scrollTop = chatBox.scrollHeight;
        }

        function formatText(text) {
          // Simple XSS protection and newline handling
          return text
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/\n/g, "<br>");
        }
      </script>
    </body>
    </html>
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: chat-ui
  namespace: ai-gateway
spec:
  replicas: 1
  selector:
    matchLabels:
      app: chat-ui
  template:
    metadata:
      labels:
        app: chat-ui
    spec:
      containers:
      - name: nginx
        image: nginxinc/nginx-unprivileged:latest
        ports:
        - containerPort: 8080
        volumeMounts:
        - name: code
          mountPath: /usr/share/nginx/html/index.html
          subPath: index.html
      volumes:
      - name: code
        configMap:
          name: chat-ui-code
---
apiVersion: v1
kind: Service
metadata:
  name: chat-ui
  namespace: ai-gateway
spec:
  selector:
    app: chat-ui
  ports:
  - port: 80
    targetPort: 8080

動作確認

Kong GatewayのIPアドレスに対してリクエストを送信し、AIからの応答が返ってくるか確認します。
AI Proxy Pluginは、リクエスト/レスポンスをOpenAIフォーマットに正規化してくれるため、どのプロバイダーに対しても同じJSON形式でリクエストできます。

# GatewayのIP (例: localhost)
GATEWAY_IP="localhost"

# Geminiへのリクエスト
curl -X POST http://$GATEWAY_IP/ai/gemini \
  -H "Content-Type: application/json" \
  -d '{ "messages": [{ "role": "user", "content": "Hello Gemini!" }] }'

# Claudeへのリクエスト
curl -X POST http://$GATEWAY_IP/ai/claude \
  -H "Content-Type: application/json" \
  -d '{ "messages": [{ "role": "user", "content": "Hello Claude!" }] }'

成功すれば、以下のようなOpenAI互換のJSONレスポンスが返ってきます。

{
    "model": "gemini-2.5-flash",
    "usage": {
        "completion_tokens": 14,
        "total_tokens": 505,
        "prompt_tokens": 2
    },
    "created": 0,
    "id": "xxx-xxx",
    "choices": [
        {
            "index": 0,
            "finish_reason": "stop",
            "message": {
                "role": "assistant",
                "content": "I'm here and ready! How can I help you today?"
            }
        }
    ],
    "object": "chat.completion"
}

次に、Chat UIからLLMへのアクセスを確認します。
ブラウザから http://localhost を開いて下さい。

スクリーンショット 2026-02-11 16.21.16.png

以下、Geminiのレスポンスです。他のLLMも同様にアクセスできました。

スクリーンショット 2026-02-11 16.22.35.png

Tips & トラブルシューティング

実装中に遭遇したいくつかの問題とその解決策を共有します。

1. Anthropic APIのCORSエラー

ブラウザから直接Kong経由でAnthropic APIを叩くと、401 Unauthorized と共に以下のエラーが発生します。

{
  "type":"error",
  "error": {
    "type": "authentication_error",
    "message": "CORS requests must set 'anthropic-dangerous-direct-browser-access' header"
  }
}

これはAnthropicのセキュリティ仕様です。Kongの request-transformer プラグインを使用して、リクエストヘッダーに anthropic-dangerous-direct-browser-access: true
を付与することで解決できます。

2. モデル名の指定

プロバイダーによっては、APIで利用可能なモデル名が厳密に決まっています。404エラーなどが発生した場合は、curl でプロバイダーの v1/models エンドポイントを直接叩いて、利用可能なモデルIDを確認するのが確実です。

まとめ

Kong GatewayのAI Proxy Pluginを使用することで、複数のAIプロバイダーを統合し、統一されたインターフェースでアプリケーションから利用できるようになりました。
これにより、アプリケーション側は「どのAIを使うか」を意識せずに開発でき、インフラ側で認証やガバナンスを一元管理できる強力な構成が実現できます。

また、Enterprise版の機能の利用により、さらに高度なAI活用が可能となります。
以下は一例です。

項目 内容
AI Semantic Cache LLMへの問い合わせ結果をキャッシュし、類似リクエストに対して再利用することで、APIコスト削減と応答速度向上を実現します
AI Semantic Prompt Guard 悪意あるプロンプト(プロンプトインジェクションなど)を検知・ブロックし、LLMアプリケーションの安全性を向上します
AI Egress Policy 組織内のデータがLLMへ送信される際に、機密情報(PIIなど)が含まれていないかチェック・マスキングします(Data Loss Prevention)

参考リンク

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?