AIコーディング 2026.06.25

Claude CodeとOpenAI APIのストリーミング実装の違い|動かない時の原因と対処法【2026年版】

タグ:Claude Code / OpenAI API / ストリーミング / API互換性 / エラー解決

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}`
    );
  }
}

次のステップ

  1. テストの自動化:各プロバイダーのAPI形式の変更を検出するテストスイートを作成する
  2. キャッシュの活用:ストリーミング完了後の結果をキャッシュして、同一クエリの重複リクエストを避ける
  3. レート制限への対応:各プロバイダーのレート制限情報を管理し、リトライロジックを実装する
  4. TypeScript型定義の拡張:各プロバイダーの独自フィールドに対応した型定義を作成する

Claude Codeの/codeコマンドを使えば、これらの応用実装の開発も効率化できます。


あわせて読みたい

参考ソース