StreamAccumulator の model_id ラッチバグを修正し、正しいコスト計算を実現

crmne/ruby_llm

Azure OpenAI のストリーミング応答で、最初の SSE チャンクが空文字列の model を返す際に、StreamAccumulator が空の model_id を固定してしまい、コスト情報が欠落していた問題を解消しました。修正により、最初の非空 model_id が正しく保持され、Message#cost が正確に算出されます。

背景

model_id が空文字列になるケース が Azure OpenAI のストリームで発生し、従来の実装では @model_id ||= chunk.model_id が空文字列(真値)を保持したまま残ってしまっていました。結果として組み立てられる Messagemodel_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_idnil または "" のときだけ代入を行うため、空文字列が最初に来ても後続の有効な 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 互換性は保たれ、追加テストにより信頼性が確認されています。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
fb648769

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文→背景→技術的変更→設計判断→まとめの5部構成が明確に示されており、各セクションに総論・各論・結論が存在する。

カスタムMarkdown構文 ⚠ WARNING

シンタックスハイライト・GitHubリンク記法の正確性

コードブロックのファイル名付きハイライトは正しい形式だが、PRリンクが `[PR #830](URL)` となっており、仕様の `[ #830 ](URL)` 形式に沿っていない。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

対象は Ruby/LMM の実装に詳しいエンジニアで、過度な基礎説明はなく適切な技術レベルで記述されている。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションは総論パラグラフで要旨を述べ、続く段落で具体例・コードを示し、最後に結論パラグラフでまとめている。トピックセンテンスで始まり、1段落1トピック・長さ適切、空行で区切られている。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事中のコードブロックは Diff の変更内容と完全に一致し、追加したテストケースも Diff に記載された内容と合致している。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`model_id`, `StreamAccumulator`, `SSE`, `cost` などの用語は PR と一致し、誤用はない。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

空文字列が真値として latch する問題と、空文字列判定に置き換える解決策が PR の説明と整合している。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

全ての主張は PR のタイトル・説明・Diff に根拠があり、推測や捏造は見られない。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR 番号 #830、モデル ID の文字列例、テストケースで使用された数値は正確。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトルは PR の内容(model_id の latch バグ修正とコスト計算の正確化)を正確に表現している。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

外部知識(LTS、リリース日程等)は一切含まれておらず、PR 情報の範囲内で完結している。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現の記載はなく、PR と矛盾する表現もない。