対象: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 で確認)。
ディレクトリ例とファイル一式
- .env を 公開領域の外に配置(例:
/home/username/.env
)。権限は 600。 - 公開領域(例:
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.php
→ 200 なら疎通OKraw
先頭のメッセージで具体的な原因を特定(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とは別)
- OpenAI Platformにログイン → 左メニュー Usage で現在の使用量を確認。
- Billing → Free trial usage に残高と有効期限が表示されます。
- 残高が無い・期限切れ → 支払い方法を登録し、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
を用意すると原因特定が速い。
※参考にされる場合は自己責任でお願いします。
ディスプレイ広告
ディスプレイ広告