Claude CodeとOpenAI APIのストリーミング実装の違い|動かない時の原因と対処法【2026年版】
Claude CodeとOpenAI APIのストリーミング実装の違い|動かない時の原因と対処法【2026年版】
この記事で解決できること
Claude Codeを使ってOpenAI互換APIを導入したものの、ストリーミング実装だけが動かないという経験はありませんか?OpenAI互換を謳うプロバイダーの多くは、実はOpenAIが推奨する標準仕様と異なる実装になっており、ストリーミングレスポンスの形式にズレが生じています。
この記事では:
- OpenAI互換APIが実は「互換性がない」理由
- ストリーミング実装で失敗する具体的な原因
- Claude Code内での修正コードの書き方
- 複数のAPIプロバイダーに対応させる実装パターン
を、実装コード付きで説明します。開発速度を落とさずにAPI切り替えに対応できるようになります。
OpenAI互換APIが互換性を失う理由
「OpenAI互換」の落とし穴
多くのAPIプロバイダーが「OpenAI互換API」を提供していますが、これは単にエンドポイントのURLやリクエスト形式がOpenAIに似ているだけという実態があります。特にストリーミング(streaming: true)を使う場合、レスポンス形式が微妙に異なるために、正規のOpenAI SDKやストリーミング処理が期待通りに動作しません。
APIが標準化されていない理由
OpenAIが提供する公式APIと、互換APIプロバイダーが提供するAPIは以下の点で異なっています:
- チャンク(chunk)の境界線の定義が曖昧:ストリーミングレスポンスは複数の小分けされたデータ片(チャンク)として返されますが、そのチャンクを分割するタイミングがプロバイダーごとに異なります
- エラーレスポンスのフォーマット差異:ストリーミング中にエラーが発生した場合、OpenAIとその他のプロバイダーではレスポンス形式が異なります
- メタデータ フィールドの有無:finish_reason、usage情報などが返されるプロバイダーと返されないプロバイダーが混在します
前提環境と動作確認対象
- Claude Code: 最新版(2026年6月時点)
- Node.js: 18.x以上
- TypeScript: 4.5以上(推奨)
- テスト対象API:OpenAI Chat Completion API、およびOpenAI互換を名乗るプロバイダーAPI
- 開発環境:VS Code + Claude Code拡張機能
ストリーミング実装が失敗する具体的なパターン
パターン1:JSONパースエラー(最も多い)
OpenAIの標準では、ストリーミングレスポンスは以下のフォーマットです:
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]}
data: [DONE]
ところが、互換APIプロバイダーの中には、この形式を完全に守らないものがあります。例えば:
{"id":"xxx","choices":[{"delta":{"content":"Hello"}}]}
{"id":"xxx","choices":[{"delta":{"content":" world"}}]}
このようにdata: プレフィックスや[DONE]トークンが存在しないレスポンスを返すAPIもあります。
パターン2:エラーハンドリングの失敗
ストリーミング中にエラーが発生する場合:
OpenAI標準の場合:
data: {"error":{"message":"Rate limit exceeded","type":"rate_limit_error"}}
互換APIの場合:
data: {"error":{"code":"RATE_LIMIT","message":"Rate limit exceeded"}}
またはHTTPヘッダーにエラーを含める場合も存在します。
パターン3:タイムアウト・接続の閉じ方の違い
OpenAI APIは確実にdata: [DONE]を送信してからHTTP接続を閉じます。しかし互換APIの中には、最後のチャンクを送った直後に接続を閉じてしまい、[DONE]トークンを送らないものもあります。
原因の診断方法(Claude Codeでの検証)
Claude Codeで実装をデバッグする場合、以下のステップで原因を特定できます。
ステップ1:ネットワークレスポンスの確認
まずは実際にどのようなレスポンスが返されているか確認します:
import fetch from 'node-fetch';
async function debugStreamResponse(apiUrl: string, apiKey: string) {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: 'Hello' }],
stream: true,
}),
});
// レスポンスの生テキストを確認
const text = await response.text();
console.log('Raw Response:');
console.log(text);
// レスポンスの各行を分析
const lines = text.split('\n');
lines.forEach((line, index) => {
if (line.trim()) {
console.log(`Line ${index}: ${line}`);
}
});
}
このコードを Claude Code 内で実行して、返されたレスポンスの形式を確認します。data: プレフィックスの有無、JSONフォーマットの形状、終了トークンの有無を記録しておきます。
ステップ2:エラーレスポンスの確認
ストリーミングリクエストで意図的にエラーを発生させ、エラー時のレスポンス形式を確認します:
async function debugErrorResponse(apiUrl: string, apiKey: string) {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer invalid-key`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: 'test' }],
stream: true,
}),
});
console.log('Status Code:', response.status);
console.log('Headers:', Object.fromEntries(response.headers));
if (!response.ok) {
const errorText = await response.text();
console.log('Error Response:', errorText);
}
}
Claude Codeでの実装修正:対応パターン別のコード
パターン別修正方法1:OpenAI標準APIに対応(参考ケース)
OpenAIの公式APIやOpenAI標準に厳密に従うプロバイダーの場合、以下の実装で動作します:
async function streamChatWithOpenAI(
apiUrl: string,
apiKey: string,
message: string,
onChunk: (content: string) => void
) {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: message }],
stream: true,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`API Error: ${error.error.message}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// 最後の不完全な行はバッファに残す
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine === 'data: [DONE]') {
break;
}
if (trimmedLine.startsWith('data: ')) {
try {
const jsonStr = trimmedLine.slice(6);
const chunk = JSON.parse(jsonStr);
if (chunk.choices && chunk.choices[0].delta?.content) {
onChunk(chunk.choices[0].delta.content);
}
} catch (e) {
console.error('Failed to parse chunk:', trimmedLine);
}
}
}
}
}
このコードは、レスポンスが確実にdata: プレフィックスと[DONE]トークンを含む場合に動作します。
パターン別修正方法2:プレフィックスなしAPIに対応(互換API型)
data: プレフィックスなしでJSONを直接返すAPIの場合:
async function streamChatWithCompatibleAPI(
apiUrl: string,
apiKey: string,
message: string,
onChunk: (content: string) => void
) {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: message }],
stream: true,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`API Error: ${error.error?.message || error.message}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
try {
// data: プレフィックスを削除(あれば)
const jsonStr = trimmedLine.startsWith('data: ')
? trimmedLine.slice(6)
: trimmedLine;
if (jsonStr === '[DONE]') {
break;
}
const chunk = JSON.parse(jsonStr);
// 複数のパターンのdeltaフィールド構造に対応
const content =
chunk.choices?.[0]?.delta?.content ||
chunk.delta?.content ||
chunk.content ||
'';
if (content) {
onChunk(content);
}
} catch (e) {
// JSON解析失敗時はスキップ
if (trimmedLine.length > 0) {
console.warn('Skipped malformed line:', trimmedLine);
}
}
}
}
}
このバージョンは:
data:プレフィックスがあってもなくても対応delta.content以外の場所にコンテンツが入っている場合にも対応- JSONパースエラーをスキップして処理を続行
パターン別修正方法3:複数プロバイダーに自動対応する実装
複数のAPIプロバイダーを使い分ける場合、API検出ロジックを組み込むと管理が簡単です:
interface StreamConfig {
apiUrl: string;
apiKey: string;
provider: 'openai' | 'compatible' | 'auto';
}
async function detectAPIFormat(
apiUrl: string,
apiKey: string
): Promise<'openai' | 'compatible'> {
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: 'test' }],
stream: true,
}),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
const { value } = await reader.read();
const text = decoder.decode(value);
// 最初の行を確認
const firstLine = text.split('\n')[0];
if (firstLine.startsWith('data: ')) {
return 'openai';
} else if (firstLine.trim().startsWith('{')) {
return 'compatible';
} else {
return 'compatible'; // 不明な場合は互換パターンで対応
}
} catch {
return 'compatible'; // エラー時は互換パターンで試す
}
}
async function streamChatAuto(
config: StreamConfig,
message: string,
onChunk: (content: string) => void
) {
let provider = config.provider;
if (provider === 'auto') {
provider = await detectAPIFormat(config.apiUrl, config.apiKey);
}
if (provider === 'openai') {
return streamChatWithOpenAI(config.apiUrl, config.apiKey, message, onChunk);
} else {
return streamChatWithCompatibleAPI(config.apiUrl, config.apiKey, message, onChunk);
}
}
つまずきやすいポイントと解決策
ポイント1:バッファリング処理の不備
問題:ストリーミングレスポンスの行が不完全な状態で到着するため、一行分のデータが複数のチャンクに分割される場合があります。不完全な行をそのままJSONパースしようとするとエラーになります。
解決策:上記のコード例で示したように、buffer変数で未処理の不完全な行を保持し、次のチャンクと結合してからJSONパースを試みてください。
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 最後の不完全な行を保持
ポイント2:HTTPステータスコードの見落とし
問題:APIエラーが発生した場合、HTTPステータスコードは4xx/5xxになります。この確認をせずにresponse.bodyを読もうとすると、エラーレスポンスをストリーミングデータとして処理してしまいます。
解決策:必ずresponse.okを確認してからボディを読む:
if (!response.ok) {
const error = await response.json();
throw new Error(`API Error: ${error.error?.message}`);
}
ポイント3:タイムアウト処理の設定忘れ
問題:ストリーミングレスポンスは長時間続く場合があります。タイムアウトを設定しないと、ネットワーク遅延時に無限待機する可能性があります。
解決策:Fetchリクエストにタイムアウトを設定:
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30秒でタイムアウト
try {
const response = await fetch(apiUrl, {
// ... その他の設定
signal: controller.signal,
});
} finally {
clearTimeout(timeoutId);
}
ポイント4:エラーレスポンスがストリーミング形式の場合
問題:一部のプロバイダーは、ストリーミング中のエラーを通常のJSON形式ではなく、ストリーミングデータ形式で返します。
対応例:
try {
const chunk = JSON.parse(jsonStr);
// エラーレスポンスの検出
if (chunk.error) {
throw new Error(chunk.error.message || JSON.stringify(chunk.error));
}
// 正常なレスポンス処理
const content = chunk.choices?.[0]?.delta?.content || '';
if (content) {
onChunk(content);
}
} catch (e) {
if (e instanceof Error && e.message.includes('JSON')) {
// JSON形式が完全でない場合はスキップ
} else {
throw e; // その他のエラーは再スロー
}
}
応用:実装をさらに堅牢にする方法
実装1:ログ記録とデバッグモード
本番環境でトラブルが発生した場合の診断を容易にするため、ストリーミング処理の詳細ログを記録する実装:
interface StreamOptions {
debug?: boolean;
logger?: (level: 'info' | 'warn' | 'error', message: string) => void;
}
async function streamChatWithLogging(
apiUrl: string,
apiKey: string,
message: string,
onChunk: (content: string) => void,
options: StreamOptions = {}
) {
const { debug = false, logger = console.log } = options;
if (debug) {
logger('info', `Starting stream to ${apiUrl}`);
}
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: message }],
stream: true,
}),
});
if (debug) {
logger('info', `Response status: ${response.status}`);
}
if (!response.ok) {
const errorText = await response.text();
logger('error', `API returned error: ${errorText}`);
throw new Error(errorText);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let chunkCount = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
if (debug) {
logger('info', `Stream completed. Total chunks: ${chunkCount}`);
}
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
try {
const jsonStr = trimmedLine.startsWith('data: ')
? trimmedLine.slice(6)
: trimmedLine;
if (jsonStr === '[DONE]') {
if (debug) {
logger('info', '[DONE] token received');
}
break;
}
const chunk = JSON.parse(jsonStr);
const content = chunk.choices?.[0]?.delta?.content || '';
if (content) {
onChunk(content);
chunkCount++;
if (debug && chunkCount <= 3) {
logger('info', `Chunk ${chunkCount}: ${content.substring(0, 50)}`);
}
}
} catch (e) {
logger('warn', `Failed to parse: ${trimmedLine.substring(0, 100)}`);
}
}
}
}
実装2:複数プロバイダーのフォールバック
第一選択のAPIがダウンした場合に別のプロバイダーに自動切り替える実装:
interface APIProvider {
name: string;
url: string;
apiKey: string;
format: 'openai' | 'compatible';
priority: number;
}
async function streamChatWithFallback(
providers: APIProvider[],
message: string,
onChunk: (content: string) => void
) {
const sortedProviders = providers.sort((a, b) => b.priority - a.priority);
let lastError: Error | null = null;
for (const provider of sortedProviders) {
try {
console.log(`Trying ${provider.name}...`);
if (provider.format === 'openai') {
await streamChatWithOpenAI(
provider.url,
provider.apiKey,
message,
onChunk
);
} else {
await streamChatWithCompatibleAPI(
provider.url,
provider.apiKey,
message,
onChunk
);
}
console.log(`Success with ${provider.name}`);
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.error(`Failed with ${provider.name}:`, lastError.message);
continue;
}
}
if (lastError) {
throw new Error(
`All providers failed. Last error: ${lastError.message}`
);
}
}
次のステップ
- テストの自動化:各プロバイダーのAPI形式の変更を検出するテストスイートを作成する
- キャッシュの活用:ストリーミング完了後の結果をキャッシュして、同一クエリの重複リクエストを避ける
- レート制限への対応:各プロバイダーのレート制限情報を管理し、リトライロジックを実装する
- TypeScript型定義の拡張:各プロバイダーの独自フィールドに対応した型定義を作成する
Claude Codeの/codeコマンドを使えば、これらの応用実装の開発も効率化できます。
あわせて読みたい
- Claude Code「Connection timeout」エラーの原因と解決方法|3つの対処法
- Claude OpusとSonnetを1つのAPIで連携させる方法|コスト削減&高速化
- 【2026年版】Claude Codeの始め方|5ステップで実装できる入門ガイド