StreamAccumulator の model_id ラッチバグを修正し、正しいコスト計算を実現
Azure OpenAI のストリーミング応答で、最初の SSE チャンクが空文字列の model を返す際に、StreamAccumulator が空の model_id を固定してしまい、コスト情報が欠落していた問題を解消しました。修正により、最初の非空 model_id が正しく保持され、Message#cost が正確に算出されます。
背景
model_id が空文字列になるケース が Azure OpenAI のストリームで発生し、従来の実装では @model_id ||= chunk.model_id が空文字列(真値)を保持したまま残ってしまっていました。結果として組み立てられる Message の model_id が "" となり、Message#model_info が内部で ModelNotFoundError を起こし、Message#cost がすべて nil を返していました。
この不一致は、ストリーミングリクエストだけでコストが取得できず、同一エンドポイントへの非ストリーミング呼び出しは正しく価格計算できるという、利用者にとって混乱を招く挙動を引き起こしていました。実際の Azure OpenAI のレスポンス例は "model": "" が最初のチャンクに含まれ、続くチャンクで正しいモデル ID が提供されます。
影響範囲 はストリーミングを利用する全ユーザーに及び、特にコスト分析や予算管理を自動化している環境で致命的な情報欠落を引き起こしていました。これらの背景から、model_id の取得ロジックを見直す必要がありました。
技術的な変更
StreamAccumulator#add のロジックを空文字列判定に置き換え ることで、最初の非空 model_id が正しく保持されるようにしました。具体的には @model_id ||= chunk.model_id を @model_id = chunk.model_id if @model_id.to_s.empty? に変更し、現在の @model_id が空文字列または nil の場合にのみ上書きします。
変更前:
def add(chunk)
RubyLLM.logger.debug { chunk.inspect } if RubyLLM.config.log_stream_debug
@model_id ||= chunk.model_id
handle_chunk_content(chunk)
accumulate_citations(chunk.citations)
end
変更後:
def add(chunk)
RubyLLM.logger.debug { chunk.inspect } if RubyLLM.config.log_stream_debug
@model_id = chunk.model_id if @model_id.to_s.empty?
handle_chunk_content(chunk)
accumulate_citations(chunk.citations)
end
この条件式は @model_id が nil または "" のときだけ代入を行うため、空文字列が最初に来ても後続の有効な model_id が上書きされます。結果として Message オブジェクトは正しいモデル ID を持ち、Message#model_info が正常に動作し、Message#cost が実際のトークン使用量に基づく金額を返すようになります。
さらに、テストケースを 2 件追加 して動作検証を行いました。1 件目は最初のチャンクが空文字列で続くチャンクに有効な model_id があるケース、2 件目は最初のチャンクがすでに有効な ID を持つケースをそれぞれ確認し、期待通りの model_id が取得できることをアサートしています。
it 'ignores an empty model_id from an initial chunk' do
accumulator = described_class.new
accumulator.add(RubyLLM::Chunk.new(role: :assistant, content: '', model_id: ''))
accumulator.add(RubyLLM::Chunk.new(role: :assistant, content: 'Hi', model_id: 'gpt-5.4-2026-03-05'))
message = accumulator.to_message(nil)
expect(message.model_id).to eq('gpt-5.4-2026-03-05')
end
it 'keeps the first non-empty model_id' do
accumulator = described_class.new
accumulator.add(RubyLLM::Chunk.new(role: :assistant, content: 'Hi', model_id: 'model-a'))
accumulator.add(RubyLLM::Chunk.new(role: :assistant, content: '!', model_id: 'model-b'))
message = accumulator.to_message(nil)
expect(message.model_id).to eq('model-a')
end
この修正は 公開 API を変更せず に済んでおり、既存のコードベースやユーザーコードへの影響はありません。@model_id の初期化ロジックだけが微調整され、他の機能は従来通りに動作します。
設計判断
最小限の状態変更で問題を解決 する方針が採用されました。model_id の空文字列判定だけを追加することで、プロバイダー固有の挙動(Azure の最初チャンクが空)に対処しつつ、汎用的なストリーム集約ロジックを保持しています。
代替案としては、プロバイダー別のハンドラを導入して特定ケースを分岐させることが考えられましたが、コードベースの複雑化と保守コスト増大を招く恐れがありました。そのため、空文字列かどうかのシンプルなチェック に留める選択が取られ、後方互換性と可読性が確保されました。
この判断は、RubyLLM が 軽量かつ一貫したストリーミング処理 を提供するという設計指針に合致しています。プロバイダー固有の例外的挙動は最小限のガードで吸収し、メインロジックは変わらず単一責任を保っています。
まとめ
StreamAccumulator が空文字列の model_id に latch していたバグを、空文字列判定による条件代入で修正したことにより、Azure OpenAI のストリーミングレスポンスでも正しいモデル情報とコストが取得できるようになりました。変更は内部ロジックのみで API 互換性は保たれ、追加テストにより信頼性が確認されています。