AI

【最短実装】PHPで作るサイト内チャット(OpenAI Responses API+サーバー中継)完全ガイド

対象:WordPress/共用サーバ/さくら等で PHP が使える環境。音声や画像は後日拡張、まずはテキストのみに絞った最短ルートです。

ChatGPT(Plus)を埋め込むのではなく、OpenAIのAPIで自前UIを作ります。APIキーは必ずサーバー側で管理し、フロントからは呼ばせません。

はじめに

  • 本稿では Responses API を使います(Assistants APIの後継ライン)。
  • チャットの流れ:ブラウザ → あなたのPHP(中継) → OpenAI API → あなたのPHP → ブラウザ
  • ChatGPT Plus と API の請求はです。APIは従量課金。PlusにAPI枠は付きません
  • まずは OpenAI APIの無料クレジットが残っていれば課金なしでテスト可能(残高と有効期限はダッシュボードの Usage/Billing で確認)。

ディレクトリ例とファイル一式

  1. .env公開領域の外に配置(例:/home/username/.env)。権限は 600
  2. 公開領域(例:public_html/php-chatgpt/)に以下を設置:
    • index.php(フロントUI)
    • api/chat.php(中継)
    • デバッグ用に api/echo.php / post-test.html / api/test_openai.php(任意)
    • 公開領域に .env を置く場合のみ .htaccess で直リンク禁止(推奨は公開領域の外に置くこと)

.env(公開領域の外・権限600)

# /home/username/.env
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_MODEL=gpt-4.1-mini
ALLOWED_ORIGIN=*   # テスト中は *、本番は https://your-domain.example に限定
※ 共有サーバで読めない場合のみ 640 を試す。APIキーは絶対にフロントへ出さない。

index.php(最小チャットUI/相対パスで中継にPOST)

<!doctype html>
<meta charset="utf-8">
<title>Site Chat (PHP + Responses API)</title>
<style>
body{font-family:system-ui,sans-serif;margin:2rem} #log p{margin:.4rem 0}
#log p.user{color:#444} #log p.bot{color:#0a0}
textarea{width:100%;height:6rem} button{padding:.6rem 1rem}
</style>
<body>
<h1>サイト内チャット(PHP中継)</h1>
<textarea id="msg" placeholder="質問を入力…"></textarea><br>
<button id="send">送信</button>
<div id="log" aria-live="polite"></div>

<script>
const $msg=document.getElementById('msg'); const $log=document.getElementById('log');
document.getElementById('send').onclick = async () => {
const message = $msg.value.trim(); if(!message) return;
addLine('あなた: '+message, 'user'); $msg.value='';
try{
const r = await fetch('api/chat.php', { // 重要:先頭に / を付けない(相対パス)
  method:'POST',
  headers:{'Content-Type':'application/json'},
  body: JSON.stringify({message})
});
const data = await r.json();
addLine('AI: ' + (data.reply || data.error || '[no reply]'), 'bot');
}catch(e){ addLine('AI: [通信エラー]', 'bot'); }
};
function addLine(text, cls){ const p=document.createElement('p'); p.textContent=text; p.className=cls; $log.appendChild(p); window.scrollTo({top:document.body.scrollHeight,behavior:'smooth'}); }
</script>
</body>

api/chat.php(中継:JSONで統一+バックオフ付き)

loadEnv('/home/xxxxx/.../.env'); のパスはあなたの環境に合わせて変更。

<?php
declare(strict_types=1);

// --- .env 読み込み ---
function loadEnv(string $path): void {
if (!is_readable($path)) return;
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line); if ($line === '' || $line[0] === '#') continue;
[$k,$v] = array_pad(explode('=', $line, 2), 2, '');
putenv(trim($k) . '=' . trim($v));
}
}
// 公開領域の外を推奨
loadEnv('/home/xxxxx/www/xxxx.com/php-chatgpt/.env'); // ←あなたの実パス

// --- CORS ---
$allowedOrigin = getenv('ALLOWED_ORIGIN') ?: '*';
header('Access-Control-Allow-Origin: ' . $allowedOrigin);
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Allow-Methods: POST, OPTIONS');
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') { http_response_code(204); exit; }
header('Content-Type: application/json; charset=utf-8');

// --- 入力(JSON) ---
$raw = file_get_contents('php://input') ?: '';
$input = json_decode($raw, true) ?: [];
$message = trim((string)($input['message'] ?? ''));
if ($message === '') { http_response_code(400); echo json_encode(['error'=>'message is required']); exit; }

// --- キー/モデル ---
$apiKey = getenv('OPENAI_API_KEY') ?: '';
$model  = getenv('OPENAI_MODEL') ?: 'gpt-4.1-mini';
if ($apiKey === '') { http_response_code(500); echo json_encode(['error'=>'server api key not configured']); exit; }

// --- ペイロード ---
$payload = [
'model' => $model,
'input' => [
['role'=>'system','content'=>'日本語で簡潔に回答。'],
['role'=>'user','content'=>$message],
],
'max_output_tokens' => 256, // 429緩和
];

// --- 呼び出し(指数バックオフ最大3回) ---
function callOpenAI(string $apiKey, array $payload, int $maxRetries=3): array {
$url='https://api.openai.com/v1/responses'; $attempt=0; $wait=1.0;
while(true){
$ch=curl_init($url);
curl_setopt_array($ch,[
  CURLOPT_RETURNTRANSFER=>true, CURLOPT_POST=>true,
  CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$apiKey,'Content-Type: application/json'],
  CURLOPT_POSTFIELDS=>json_encode($payload, JSON_UNESCAPED_UNICODE),
  CURLOPT_TIMEOUT=>45, CURLOPT_IPRESOLVE=>CURL_IPRESOLVE_V4
]);
$res=curl_exec($ch); $err=curl_error($ch); $code=(int)curl_getinfo($ch,CURLINFO_RESPONSE_CODE); curl_close($ch);

if($err!=='' || $res===false){
  if($attempt < $maxRetries){ usleep((int)($wait*1e6)); $attempt++; $wait*=2; continue; }
  return ['ok'=>false,'code'=>0,'err'=>$err?:'network error','raw'=>null];
}
if($code===429){
  if($attempt < $maxRetries){ usleep((int)($wait*1e6)); $attempt++; $wait*=2; continue; }
  return ['ok'=>false,'code'=>$code,'err'=>'rate or quota limit (429)','raw'=>$res];
}
if($code>=400){ return ['ok'=>false,'code'=>$code,'err'=>'api error','raw'=>$res]; }
return ['ok'=>true,'code'=>$code,'err'=>'','raw'=>$res];
}
}
$r = callOpenAI($apiKey, $payload);

if(!$r['ok']){
http_response_code(502);
echo json_encode([
'error' => $r['err'].' (code='.$r['code'].')',
'raw'   => is_string($r['raw']) ? mb_substr($r['raw'],0,400) : null,
'hint'  => 'Billing/Usage・モデル名・連投・WAF/TLSを確認'
], JSON_UNESCAPED_UNICODE); exit;
}

// --- 出力テキスト抽出 ---
$data = json_decode((string)$r['raw'], true) ?: [];
$reply = $data['output_text'] ?? '';
if ($reply === '' && !empty($data['output'])) {
foreach ($data['output'] as $item) {
if (($item['type'] ?? '') === 'message') {
  foreach (($item['content'] ?? []) as $c) {
    if (($c['type'] ?? '') === 'output_text') $reply .= (string)($c['text'] ?? '');
  }
}
}
}
echo json_encode(['reply'=>$reply!==''?$reply:'[no text output]'], JSON_UNESCAPED_UNICODE);

デバッグ用:api/echo.php(届いたボディをそのまま返す)

<?php
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'method' => $_SERVER['REQUEST_METHOD'] ?? '',
'content_type' => $_SERVER['CONTENT_TYPE'] ?? '',
'raw' => file_get_contents('php://input') ?: '',
'post' => $_POST,
], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT);

デバッグ用:post-test.html(JSON/フォームでPOSTテスト)

<!doctype html>
<meta charset="utf-8">
<h1>POSTテスト</h1>
<button id="json">JSONでPOST</button>
<button id="form">フォームでPOST</button>
<pre id="out"></pre>
<script>
const out=document.getElementById('out');
document.getElementById('json').onclick=async()=>{
const r=await fetch('api/echo.php',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:'JSONで送信'})});
out.textContent=await r.text();
};
document.getElementById('form').onclick=async()=>{
const p=new URLSearchParams(); p.set('message','FORMで送信');
const r=await fetch('api/echo.php',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:p});
out.textContent=await r.text();
};
</script>

疎通テスト:api/test_openai.php(キー・外向き通信・TLS切り分け)

<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
function loadEnv($p){ if(is_readable($p)){ foreach(file($p, FILE_IGNORE_NEW_LINES|FILE_SKIP_EMPTY_LINES) as $l){ $l=trim($l); if($l===''||$l[0]==='#')continue; [$k,$v]=array_pad(explode('=',$l,2),2,''); putenv(trim($k).'='.trim($v)); } } }
loadEnv('/home/xxxxx/www/xxxx.com/php-chatgpt/.env'); // ←あなたの実パス
$apiKey=getenv('OPENAI_API_KEY')?:''; if($apiKey===''){ http_response_code(500); echo json_encode(['ok'=>false,'why'=>'no OPENAI_API_KEY']); exit; }
$ch=curl_init('https://api.openai.com/v1/models');
curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$apiKey],CURLOPT_TIMEOUT=>30,CURLOPT_IPRESOLVE=>CURL_IPRESOLVE_V4]);
$res=curl_exec($ch); $err=curl_error($ch); $code=(int)curl_getinfo($ch,CURLINFO_RESPONSE_CODE); curl_close($ch);
echo json_encode(['ok'=>$err===''&&$code<400,'code'=>$code,'err'=>$err,'raw'=>$res?mb_substr($res,0,500):null], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT);

(公開領域に .env を置く場合のみ).htaccess の直リンク禁止

<FilesMatch "^\.env$">Require all denied</FilesMatch>
<Files ~ "^\.(?!well-known/)">Require all denied</Files>

注意点(必読)

  • APIキーはサーバーのみに保持。フロントへ露出すると不正利用=高額課金のリスク。
  • .envは公開領域の外に置く(最良)。やむを得ず公開領域なら .htaccess でブロック。
  • パーミッション:.envは600(読めないときのみ 640)。PHP/HTMLは 644、ディレクトリは 755 が無難。
  • 相対パス:サブディレクトリ配下(例:/php-chatgpt/)なら fetch('api/chat.php') のように先頭スラッシュを付けない。
  • CORS:テスト中は ALLOWED_ORIGIN=* でも良いが、本番は自ドメインに限定。
  • ChatGPT Plus ≠ API:Plusの料金はAPI従量課金に充当されません。APIは別課金。
  • 無料枠:OpenAI Platformの Usage/Billing で残高・有効期限を確認。無い場合は支払い方法登録が必要。

エラーになった場合:原因と確認事項

1) message is required(HTTP 400)

主な原因

  • フロントから本文が送れていない
  • Content-Type の不一致(JSONなのに x-www-form-urlencoded 等)
  • パス違い(絶対/相対)で別URLに飛んでいる
確認

  • Networkタブ → Request Payload{"message":"..."} が入っているか
  • fetch('api/chat.php', { headers:{'Content-Type':'application/json'}, body: JSON.stringify({message}) })
  • post-test.html → JSONでPOSTが成功するか

2) 502 (Bad Gateway) & 本文に OpenAI API error (…)

主な原因

  • OpenAI側へのリクエストでHTTPエラー
  • ネットワーク/名前解決/TLS(共有サーバ環境依存)
  • モデル名の誤り、WAFによるブロック など
確認

  • api/test_openai.php200 なら疎通OK
  • raw 先頭のメッセージで具体的な原因を特定(model_not_found 等)
  • モデル名は例:gpt-4.1-mini を使用

3) 429(rate / quota / context)

主な原因

  • insufficient_quota:無料枠切れ/支払い方法未設定/上限到達
  • rate_limit_exceeded:短時間の過剰リクエスト
  • context_length_exceeded:入力+出力のトークン上限超過
対処

  • OpenAIダッシュボードの Usage / Limits を確認(必要に応じて上限引き上げ)
  • 無料枠が無いなら支払い方法を登録
  • max_output_tokens を小さく(例:256 → 128)
  • フロントで連投防止(送信中はボタンを disabled)
  • サーバーで指数バックオフ(本稿の chat.php は実装済み)

4) CORS・相対パス

  • サブディレクトリ配下なら 相対パスapi/chat.php を呼ぶ(先頭に / を付けない)。
  • テスト中は ALLOWED_ORIGIN=*、本番は自ドメインに戻す。

5) WAF/セキュリティ製品

  • JSON POSTがブロックされる環境あり。post-test.html の「フォームでPOST」が通るか試す。
  • 必要に応じて除外ルールを設定、またはAPI中継パスのみ緩和。

6) TLS/IPv6/証明書ストア

  • 共有サーバの古い OpenSSL/証明書ストアが原因のことあり。
  • CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4 はIPv6絡みの不具合回避に有効。

無料枠で試す(ChatGPT Plusとは別)

  1. OpenAI Platformにログイン → 左メニュー Usage で現在の使用量を確認。
  2. BillingFree trial usage に残高と有効期限が表示されます。
  3. 残高が無い・期限切れ → 支払い方法を登録し、Usage limits(Soft/Hard)を低めに設定して開始。

※ ChatGPT Plus を契約していても、API利用は別契約・別従量課金です。

まとめ

  • 最短の構成は「フロント(index.php)中継(chat.php)OpenAI Responses API」。
  • .envは公開領域外&600、APIキーは絶対にフロントへ出さない
  • 相対パス/CORS/Content-Type(JSON) を統一するとトラブルが激減。
  • 429は「無料枠切れ/上限/連投/トークン量」が原因の大半。ダッシュボード確認+出力制限+バックオフで解消。
  • デバッグ用に echo.php / post-test.html / test_openai.php を用意すると原因特定が速い。

 
※参考にされる場合は自己責任でお願いします。