— 404から401まで層を剥がして実測した互換性の境界
TL;DR(結論の表を最初に置く)
| 層 | 互換性 | 根拠 |
|---|---|---|
| リクエスト/レスポンスのスキーマ | ✅ 同一 | client_secrets のボディ形式、イベントプロトコルとも本家と同型 |
session.model の意味 |
⚠️ 名前空間が異なる | 本家=グローバルモデル名 / Azure=デプロイメント名 |
| ブラウザ直WSの認証(サブプロトコル方式) | ❌ 401(実測) | Azure公式のWS認証3方式(後述)に含まれず、記載もなし |
| エフェメラルキーのTTL | ⚠️ 大幅に異なる | 本家公称: デフォルト10分・上限2時間 / Azure実測: 約3時間(本家の上限すら超える) |
- 「互換」はAPIスキーマの層では成立するが、接続トランスポートと設定値の層では成立しない
- ブラウザからAzureに直接続する経路として安全に選べるのはWebRTCが実質唯一。api-keyクエリパラメータも公式には存在するが、失効しない標準キーをブラウザに配ることになるため実運用では選べない。WS化するならサーバー中継構成が前提
- デフォルトTTLに依存せず
expires_afterを明示指定すべき
1. 前提: 検証環境と構成
- 検証の動機: OpenAI Realtime API(WebRTC/WS)で動いている既存実装を、Azure OpenAIのキーとエンドポイントに差し替えたら動くのか?という素朴な疑問
- 環境:
- Next.js + TypeScript
openainpmパッケージ(本家SDK)+@openai/agents-realtime- Azure OpenAI リソース(GA v1エンドポイント)、gpt-realtime系モデルのデプロイメント
- 用語の前提(1段落で):
- Azureには「モデル」の上に「デプロイメント」という抽象があり、自分で任意の名前を付ける
- GA (v1) APIとPreview APIでURL構造がまったく違う(本文で後述)
2. 実録: エラーコードで層を降りる
2.1 404 Resource not found — URLの世代が違う
- 症状: SDKからの接続で404
- 原因: AzureのRealtime APIはURL形式が2世代ある
# Preview(旧)
wss://{resource}.openai.azure.com/openai/realtime?api-version=2025-04-01-preview&deployment={name}
# GA(現行)
wss://{resource}.openai.azure.com/openai/v1/realtime?model={name}
(Previewのapi-versionは検証当時2024-10-01-previewでも同様。現行ドキュメントの例は2025-04-01-preview) - 世代の混在(GAパスにPreviewパラメータ等)も404/400になる。これは公式ドキュメントにも明記がある:
Using the wrong path or mixing GA/preview formats results in a 404 error.(Microsoft Learn、参考リンク[1])
- 確定: パスをGA形式に統一 → 404は解消し400へ(=パス解決は通った)
2.2 400 Bad Request — model パラメータの欠落
- GAパスでは
?model=が必須。deployment=やapi-version=はGAでは使わない - 確定:
modelを渡す → エラーがOperationNotSupportedに変化(=スキーマ検証は通った)
2.3 OperationNotSupported — 「model」の意味が本家と違う
- エラー:
The realtime operation does not work with the specified model - 原因:
session.modelに 本家のモデル名 を渡していた - Azureでは
model= Azureポータルの「モデル デプロイ」一覧の「名前」列(=デプロイメント名) - デフォルトのデプロイ名はモデル名と一致するため、偶然動く環境では罠が隠蔽される
- 確定: デプロイメント名を渡す → client_secrets の発行に成功
2.4 寄り道: .env の引用符問題
MODEL="A"とMODEL=Aは、ローダーによって解釈が違う- dotenv / Next.js組み込み → 引用符を剥がす。docker –env-file / ECS environmentFiles → 引用符ごと値になる
- 教訓: 環境変数起因を疑うときは
console.log(JSON.stringify(process.env.X))で可視化する
2.5 WS接続が無応答 → wscatで層を降りる
- ブラウザ(agents-realtime経由)ではハンドシェイクに応答が返らない
- SDKを剥がして生のWSクライアントで検証:
bash
npx wscat -c "wss://{resource}.openai.azure.com/openai/v1/realtime?model={deployment}" \
-H "api-key: $AZURE_OPENAI_API_KEY" - 確定: api-keyヘッダー(サーバー間相当)では接続成功 → サーバー・パス・デプロイは正常。問題はブラウザ経路の認証に絞られた
2.6 401 Unauthorized — サブプロトコル方式の拒否
- 本家のブラウザWS認証: サブプロトコル欄にトークンを載せる規約
js
new WebSocket(url, ["realtime", "openai-insecure-api-key." + key]); - これは標準の認証方式ではなく、Sec-WebSocket-Protocol欄への相乗り(OpenAIローカルの規約)である点を解説
- 同方式をAzureに向けると401(実測)
- Azure公式ドキュメント(参考リンク[1])がWS認証として挙げるのは次の3方式のみで、サブプロトコル方式の記載はない:
- Microsoft Entra の Bearer トークン(
Authorizationヘッダー) api-key接続ヘッダー — ただし「ブラウザ環境では利用不可」と明記api-keyクエリパラメータ — ブラウザからも技術的には使えるが、失効しない標準キーをクライアントに配ることになる- つまり「ブラウザから安全に直結する経路」としてはWebRTCしか残らない、が現時点の結論
- 正直な限界を一つ: トークンを渡すトランスポート(ヘッダー/サブプロトコル/クエリ)ごとの総当たり対照実験まではしていない。本記事の根拠は「サブプロトコル方式での401(実測)」と「公式ドキュメントに同方式の記載がないこと」の2点である
3. おまけの実測: エフェメラルキーのTTLが本家公称と違う
- 本家公称(現行GAの client_secrets リファレンス、参考リンク[3]):
expires_after.secondsはデフォルト600秒(10分)、指定可能範囲10〜7200秒(2時間) - 注意: ネット上に残る「エフェメラルトークンは1分で失効」という記述はbeta時代の
/v1/realtime/sessionsのもの。現行GAの client_secrets とは別物なので、古い記事を読むときは世代に注意 - なお本家でも「公称と実際の expires_at が食い違う」というコミュニティ報告が過去にあり(参考リンク[4])、TTL周りの記述は本家も揺れてきた経緯がある
- Azure実測(検証時):
expires_at - 発行時刻≒ 約3時間 - つまり本家仕様の上限7200秒すら超えるデフォルトであり、スキーマ互換でも「値の意味論」が違う例がもう一つ増えることになる
- 検証方法: 素のcurlで
expires_after指定なしで発行し、jqでTTL秒を算出
bash
curl -s -X POST ".../openai/v1/realtime/client_secrets" \
-H "api-key: $KEY" -H "Content-Type: application/json" \
-d '{"session":{"type":"realtime","model":"{deployment}"}}' \
| jq '{expires_at, ttl_sec: (.expires_at - (now|floor))}' - 補足: client secret の失効は「そのキーで新規接続を開始できる期限」であり、開始済みセッションの継続は失効後も切られない(本家リファレンスの定義)。漏洩リスクを見積もるときはこの意味で読む
- セキュリティ含意: 「エフェメラル」の名で数時間生きるトークンをブラウザに配るのは漏洩窓が広すぎる
- 対策は1行:
"expires_after": {"anchor": "created_at", "seconds": 120}を明示指定(anchorは現状created_atのみ) - 教訓: デフォルト値はベンダーの都合であって、セキュリティ要件ではない
4. ではどう設計するか
4.1 直結構成とリレー構成のトレードオフ
| 直結(ブラウザ → プロバイダ) | リレー(ブラウザ → 自社サーバ → プロバイダ) | |
|---|---|---|
| 経路 | WebRTC(WS直結は上記のとおりAzureでは実質不可) | ブラウザ⇔自社サーバ間WS + サーバ間WS |
| レイテンシ | 最小(本家推奨) | 数十ms増 + サーバで音声を終端する実装コスト |
| 認証 | プロバイダ固有の規約(ek_発行フロー等)に密結合 | プロバイダ依存区間がサーバー間WSに限定。ヘッダー認証で本家/Azureとも接続可(実測済み)。ek_の仕組み自体が不要 |
| クライアント認証 | プロバイダ規約に従う | 自社基盤(Cognito等)に統一できる |
| 可搬性 | 乗り換えが「設定変更」でなく「再設計」になる | プロファイル差し替えで済む |
| 運用 | 企業ネットワークのTLSインスペクション等と衝突しやすい | 443のWSSでLB配下に収まり、監査証跡も取れる |
4.2 プロバイダ可搬性を残したいなら
- 接続URL・認証ヘッダー・model値を設定モジュールに集約し、プロバイダごとのプロファイルとして分離する
- 「本家用のモデル名」をコードにハードコードしない(Azureではデプロイ名になるため)
5. まとめ: 互換レイヤーは主要経路しか保証しない
- 互換を謳うサービスの互換性には勾配がある: よく使われる経路ほど互換性が高く、周縁の経路ほど検証されていない
- 今回踏んだ「ブラウザ直WS」は本家ですらWebRTC推奨の周縁経路であり、勾配の一番薄い場所だった
- チェックリスト(自分が使う経路は主要経路か?):
- [ ] その構成、移行先の公式ドキュメントに載っているか
- [ ]
model等の設定値の意味論は同じか(スキーマ互換≠値互換) - [ ] 認証トランスポートはSDK任せにできる層か
- [ ] デフォルト値(TTL等)を実測したか
付録: 層を降りる道具箱
| 層 | 素手のクライアント | 今回の用途 |
|---|---|---|
| HTTP | curl(--http1.1でUpgrade検証) |
エラーボディの回収、ハンドシェイクの生応答 |
| WebSocket | wscat / websocat | SDKを剥がした接続検証、-H/-sでの認証方式の切り替え検証 |
| 値の可視化 | JSON.stringify / jq |
.env引用符、TTL算出 |
参考リンク
- Use the GPT Realtime API via WebSockets — Microsoft Learn(GA/PreviewのURL形式、WS認証の3方式)
- Use the GPT Realtime API via WebRTC — Microsoft Learn(client_secrets エンドポイントとエフェメラルトークンのフロー)
- Create client secret — OpenAI API Reference(
expires_afterのデフォルト600秒・範囲10〜7200秒) - Question About Ephemeral Key TTL in Realtime API — OpenAI Developer Community(本家でも公称と実測が食い違った報告)