1415 lines
49 KiB
Rust
1415 lines
49 KiB
Rust
//! OpenAI Responses API の SSE イベントパース
|
||
//!
|
||
//! `response.*` 名前空間の SSE を共通の [`Event`](crate::llm_client::event::Event)
|
||
//! に変換する。Responses の (output_index, content_index) 2 次元座標と
|
||
//! この crate の 1 次元 `BlockStart/Delta/Stop::index` のマッピングは
|
||
//! [`OpenAIResponsesState`] が保持する。
|
||
|
||
use std::collections::{BTreeMap, HashMap};
|
||
|
||
use serde::Deserialize;
|
||
use serde_json::{Map, Value};
|
||
|
||
use crate::llm_client::{
|
||
ClientError,
|
||
event::{
|
||
BlockDelta, BlockMetadata, BlockStart, BlockStop, BlockType, DeltaContent, ErrorEvent,
|
||
Event, ReasoningBlockData, ResponseStatus, StatusEvent, UnhandledSseEvent, UsageEvent,
|
||
},
|
||
};
|
||
|
||
/// SSE パース中の座標 → flat block index マップ。
|
||
#[derive(Debug, Default)]
|
||
pub struct OpenAIResponsesState {
|
||
slots: HashMap<SlotKey, SlotInfo>,
|
||
next_index: usize,
|
||
/// 蓄積中の reasoning output_item。`output_item.added`(Reasoning) で
|
||
/// 確保し、`reasoning_text.delta` / `reasoning_summary_text.delta` で
|
||
/// 蓄積、`output_item.done`(Reasoning) で既存 reasoning_text block または
|
||
/// metadata-only Thinking block に reasoning persistence material を載せる。
|
||
pending_reasoning: HashMap<usize, PendingReasoning>,
|
||
}
|
||
|
||
/// 1 つの reasoning output_item の蓄積バッファ。
|
||
#[derive(Debug, Default)]
|
||
struct PendingReasoning {
|
||
id: Option<String>,
|
||
/// `reasoning_text.delta` の累積。複数 content_part あれば順に concat。
|
||
text: String,
|
||
/// `reasoning_summary_text.delta` を summary_index 順に蓄積。
|
||
summary: Vec<String>,
|
||
/// `response.content_part.done` が先に到着した reasoning/thinking block。
|
||
///
|
||
/// `response.output_item.done` まで待たないと encrypted_content や最終
|
||
/// summary が揃わないため、live-visible な余分な synthetic Thinking block
|
||
/// を作らず、既存 block の stop に persistence metadata を載せる。
|
||
deferred_thinking_stops: Vec<SlotInfo>,
|
||
}
|
||
|
||
impl PendingReasoning {
|
||
fn into_reasoning_data(self, encrypted_content: Option<String>) -> ReasoningBlockData {
|
||
ReasoningBlockData {
|
||
id: self.id,
|
||
text: Some(self.text),
|
||
summary: self.summary.into_iter().filter(|s| !s.is_empty()).collect(),
|
||
encrypted_content,
|
||
signature: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl OpenAIResponsesState {
|
||
fn allocate(&mut self, key: SlotKey, block_type: BlockType) -> SlotInfo {
|
||
let info = SlotInfo {
|
||
flat_index: self.next_index,
|
||
block_type,
|
||
};
|
||
self.next_index += 1;
|
||
self.slots.insert(key, info);
|
||
info
|
||
}
|
||
|
||
/// 既存 slot を取得。無ければ `block_type` で暗黙に確保し、
|
||
/// 新規確保したかを併せて返す。delta 先行 / content_part.added が
|
||
/// 抜けたときの防御。
|
||
fn get_or_allocate(&mut self, key: SlotKey, block_type: BlockType) -> (SlotInfo, bool) {
|
||
if let Some(info) = self.slots.get(&key).copied() {
|
||
(info, false)
|
||
} else {
|
||
(self.allocate(key, block_type), true)
|
||
}
|
||
}
|
||
|
||
fn ensure_reasoning(&mut self, output_index: usize) -> &mut PendingReasoning {
|
||
self.pending_reasoning.entry(output_index).or_default()
|
||
}
|
||
|
||
fn extend_reasoning_summary(&mut self, output_index: usize, summary_index: usize, text: &str) {
|
||
let entry = self.ensure_reasoning(output_index);
|
||
if entry.summary.len() <= summary_index {
|
||
entry.summary.resize(summary_index + 1, String::new());
|
||
}
|
||
entry.summary[summary_index].push_str(text);
|
||
}
|
||
|
||
fn defer_reasoning_stop(&mut self, output_index: usize, info: SlotInfo) {
|
||
self.ensure_reasoning(output_index)
|
||
.deferred_thinking_stops
|
||
.push(info);
|
||
}
|
||
|
||
fn take_active_reasoning_slots(&mut self, output_index: usize) -> Vec<SlotInfo> {
|
||
let mut keys: Vec<_> = self
|
||
.slots
|
||
.iter()
|
||
.filter_map(|(key, info)| match key {
|
||
SlotKey::ContentPart { output, content }
|
||
if *output == output_index && info.block_type == BlockType::Thinking =>
|
||
{
|
||
Some((*content, *key))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
keys.sort_by_key(|(content, _)| *content);
|
||
keys.into_iter()
|
||
.filter_map(|(_, key)| self.slots.remove(&key))
|
||
.collect()
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
enum SlotKey {
|
||
/// tool_use (function_call / custom_tool_call) — output_item 全体で 1 block
|
||
OutputItem(usize),
|
||
/// message の output_text / reasoning item の reasoning_text
|
||
ContentPart { output: usize, content: usize },
|
||
/// reasoning item の summary_text (summary_index)
|
||
Summary { output: usize, summary: usize },
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy)]
|
||
struct SlotInfo {
|
||
flat_index: usize,
|
||
block_type: BlockType,
|
||
}
|
||
|
||
// ============================================================================
|
||
// SSE イベントの JSON 構造
|
||
// ============================================================================
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct OutputItemAdded {
|
||
output_index: usize,
|
||
item: OutputItem,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct OutputItemDone {
|
||
output_index: usize,
|
||
#[allow(dead_code)]
|
||
item: OutputItem,
|
||
}
|
||
|
||
/// `response.output_item.added/done` の `item`。
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(tag = "type", rename_all = "snake_case")]
|
||
enum OutputItem {
|
||
Message {
|
||
#[allow(dead_code)]
|
||
id: Option<String>,
|
||
},
|
||
Reasoning {
|
||
#[serde(default)]
|
||
id: Option<String>,
|
||
/// `output_item.done` で初めて埋まる。`include=["reasoning.encrypted_content"]`
|
||
/// 指定時に opaque blob が乗る。
|
||
#[serde(default)]
|
||
encrypted_content: Option<String>,
|
||
},
|
||
FunctionCall {
|
||
#[allow(dead_code)]
|
||
#[serde(default)]
|
||
id: Option<String>,
|
||
call_id: String,
|
||
name: String,
|
||
},
|
||
CustomToolCall {
|
||
#[allow(dead_code)]
|
||
#[serde(default)]
|
||
id: Option<String>,
|
||
call_id: String,
|
||
name: String,
|
||
},
|
||
#[serde(other)]
|
||
Other,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct ContentPartAdded {
|
||
output_index: usize,
|
||
content_index: usize,
|
||
part: ContentPart,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct ContentPartDone {
|
||
output_index: usize,
|
||
content_index: usize,
|
||
#[allow(dead_code)]
|
||
part: ContentPart,
|
||
}
|
||
|
||
/// `response.content_part.added/done` の `part`。
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(tag = "type", rename_all = "snake_case")]
|
||
enum ContentPart {
|
||
OutputText {
|
||
#[allow(dead_code)]
|
||
#[serde(default)]
|
||
text: String,
|
||
},
|
||
ReasoningText {
|
||
#[allow(dead_code)]
|
||
#[serde(default)]
|
||
text: String,
|
||
},
|
||
#[serde(other)]
|
||
Other,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct OutputTextDelta {
|
||
output_index: usize,
|
||
content_index: usize,
|
||
delta: String,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct ReasoningTextDelta {
|
||
output_index: usize,
|
||
content_index: usize,
|
||
delta: String,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct ReasoningSummaryPartAdded {
|
||
output_index: usize,
|
||
summary_index: usize,
|
||
#[allow(dead_code)]
|
||
#[serde(default)]
|
||
part: Option<serde_json::Value>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct ReasoningSummaryTextDelta {
|
||
output_index: usize,
|
||
summary_index: usize,
|
||
delta: String,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct ReasoningSummaryPartDone {
|
||
output_index: usize,
|
||
summary_index: usize,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct FunctionCallArgumentsDelta {
|
||
output_index: usize,
|
||
delta: String,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct CustomToolCallInputDelta {
|
||
output_index: usize,
|
||
delta: String,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct ResponseCompleted {
|
||
response: CompletedResponse,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct CompletedResponse {
|
||
#[serde(default)]
|
||
usage: Option<ResponsesUsage>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct ResponsesUsage {
|
||
#[serde(default)]
|
||
input_tokens: Option<u64>,
|
||
#[serde(default)]
|
||
output_tokens: Option<u64>,
|
||
#[serde(default)]
|
||
total_tokens: Option<u64>,
|
||
/// `input_tokens` の内訳。`cached_tokens` がプロンプトキャッシュヒット分。
|
||
#[serde(default)]
|
||
input_tokens_details: Option<InputTokensDetails>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct InputTokensDetails {
|
||
#[serde(default)]
|
||
cached_tokens: Option<u64>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct ResponseFailed {
|
||
response: FailedResponse,
|
||
#[serde(flatten)]
|
||
extra: BTreeMap<String, Value>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct FailedResponse {
|
||
#[serde(default)]
|
||
error: Option<ErrorDetail>,
|
||
#[serde(flatten)]
|
||
extra: BTreeMap<String, Value>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct ErrorDetail {
|
||
#[serde(rename = "type", default)]
|
||
error_type: Option<String>,
|
||
#[serde(default)]
|
||
message: Option<String>,
|
||
#[serde(default)]
|
||
code: Option<String>,
|
||
#[serde(flatten)]
|
||
extra: BTreeMap<String, Value>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct TopLevelErrorEnvelope {
|
||
error: TopLevelError,
|
||
#[serde(flatten)]
|
||
extra: BTreeMap<String, Value>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct TopLevelError {
|
||
#[serde(default)]
|
||
message: Option<String>,
|
||
#[serde(rename = "type", default)]
|
||
error_type: Option<String>,
|
||
#[serde(default)]
|
||
code: Option<String>,
|
||
#[serde(flatten)]
|
||
extra: BTreeMap<String, Value>,
|
||
}
|
||
|
||
// ============================================================================
|
||
// parse entry point
|
||
// ============================================================================
|
||
|
||
/// SSE フレーム 1 件をパースし、0 個以上の [`Event`] に変換する。
|
||
///
|
||
/// `event_type` は SSE の `event:` フィールド。未対応の event type は
|
||
/// [`Event::UnhandledSse`] として観測可能にする。`data` が JSON でない /
|
||
/// 必要なフィールドが抜けている等は [`ClientError::Api`] で返す。
|
||
pub(crate) fn parse_sse(
|
||
event_type: &str,
|
||
data: &str,
|
||
state: &mut OpenAIResponsesState,
|
||
) -> Result<Vec<Event>, ClientError> {
|
||
match event_type {
|
||
"response.created" => Ok(vec![Event::Status(StatusEvent {
|
||
status: ResponseStatus::Started,
|
||
})]),
|
||
|
||
"response.completed" => {
|
||
let ev: ResponseCompleted = from_json(data)?;
|
||
let mut out = Vec::new();
|
||
if let Some(usage) = ev.response.usage {
|
||
out.push(Event::Usage(UsageEvent {
|
||
input_tokens: usage.input_tokens,
|
||
output_tokens: usage.output_tokens,
|
||
total_tokens: usage.total_tokens.or_else(|| {
|
||
Some(usage.input_tokens.unwrap_or(0) + usage.output_tokens.unwrap_or(0))
|
||
}),
|
||
cache_read_input_tokens: usage
|
||
.input_tokens_details
|
||
.and_then(|d| d.cached_tokens),
|
||
// Responses API は cache 書き込みを別計上しない(input_tokens に含まれる)
|
||
cache_creation_input_tokens: None,
|
||
}));
|
||
}
|
||
out.push(Event::Status(StatusEvent {
|
||
status: ResponseStatus::Completed,
|
||
}));
|
||
Ok(out)
|
||
}
|
||
|
||
"response.failed" | "response.incomplete" => {
|
||
let ev: ResponseFailed = from_json(data)?;
|
||
let (code, message) = response_failure_diagnostic(event_type, ev);
|
||
Ok(vec![
|
||
Event::Error(ErrorEvent { code, message }),
|
||
Event::Status(StatusEvent {
|
||
status: ResponseStatus::Failed,
|
||
}),
|
||
])
|
||
}
|
||
|
||
"response.output_item.added" => {
|
||
let ev: OutputItemAdded = from_json(data)?;
|
||
match ev.item {
|
||
OutputItem::FunctionCall { call_id, name, .. }
|
||
| OutputItem::CustomToolCall { call_id, name, .. } => {
|
||
let info =
|
||
state.allocate(SlotKey::OutputItem(ev.output_index), BlockType::ToolUse);
|
||
Ok(vec![Event::BlockStart(BlockStart {
|
||
index: info.flat_index,
|
||
block_type: BlockType::ToolUse,
|
||
metadata: BlockMetadata::ToolUse { id: call_id, name },
|
||
})])
|
||
}
|
||
OutputItem::Reasoning { id, .. } => {
|
||
// wrapper を確保。中身の content_part / summary_part は
|
||
// 別 SlotKey で扱われ続ける(Streaming 表示は維持)。
|
||
let entry = state.ensure_reasoning(ev.output_index);
|
||
if id.is_some() {
|
||
entry.id = id;
|
||
}
|
||
Ok(Vec::new())
|
||
}
|
||
_ => Ok(Vec::new()),
|
||
}
|
||
}
|
||
|
||
"response.output_item.done" => {
|
||
let ev: OutputItemDone = from_json(data)?;
|
||
// Reasoning wrapper の done で蓄積分を既存 reasoning_text block の
|
||
// stop に載せる。content_part.done が先に来た場合は stop を defer
|
||
// しておき、ここで encrypted_content / summary と一緒に完了させる。
|
||
// reasoning_text が無い metadata-only item だけ synthetic block を作る。
|
||
if let OutputItem::Reasoning {
|
||
id,
|
||
encrypted_content,
|
||
..
|
||
} = ev.item
|
||
{
|
||
let mut pending = state
|
||
.pending_reasoning
|
||
.remove(&ev.output_index)
|
||
.unwrap_or_default();
|
||
if pending.id.is_none() {
|
||
pending.id = id;
|
||
}
|
||
|
||
let mut stop_blocks = std::mem::take(&mut pending.deferred_thinking_stops);
|
||
stop_blocks.extend(state.take_active_reasoning_slots(ev.output_index));
|
||
let reasoning = pending.into_reasoning_data(encrypted_content);
|
||
|
||
if stop_blocks.is_empty() {
|
||
let info =
|
||
state.allocate(SlotKey::OutputItem(ev.output_index), BlockType::Thinking);
|
||
state.slots.remove(&SlotKey::OutputItem(ev.output_index));
|
||
return Ok(vec![
|
||
Event::BlockStart(BlockStart {
|
||
index: info.flat_index,
|
||
block_type: BlockType::Thinking,
|
||
metadata: BlockMetadata::Thinking,
|
||
}),
|
||
Event::BlockStop(BlockStop {
|
||
index: info.flat_index,
|
||
block_type: BlockType::Thinking,
|
||
stop_reason: None,
|
||
reasoning: Some(reasoning),
|
||
}),
|
||
]);
|
||
}
|
||
|
||
let last = stop_blocks.len() - 1;
|
||
return Ok(stop_blocks
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(idx, info)| {
|
||
Event::BlockStop(BlockStop {
|
||
index: info.flat_index,
|
||
block_type: info.block_type,
|
||
stop_reason: None,
|
||
reasoning: (idx == last).then(|| reasoning.clone()),
|
||
})
|
||
})
|
||
.collect());
|
||
}
|
||
if let Some(info) = state.slots.remove(&SlotKey::OutputItem(ev.output_index)) {
|
||
Ok(vec![Event::BlockStop(BlockStop {
|
||
index: info.flat_index,
|
||
block_type: info.block_type,
|
||
stop_reason: None,
|
||
reasoning: None,
|
||
})])
|
||
} else {
|
||
Ok(Vec::new())
|
||
}
|
||
}
|
||
|
||
"response.content_part.added" => {
|
||
let ev: ContentPartAdded = from_json(data)?;
|
||
let (block_type, metadata) = match ev.part {
|
||
ContentPart::OutputText { .. } => (BlockType::Text, BlockMetadata::Text),
|
||
ContentPart::ReasoningText { .. } => (BlockType::Thinking, BlockMetadata::Thinking),
|
||
ContentPart::Other => return Ok(Vec::new()),
|
||
};
|
||
let info = state.allocate(
|
||
SlotKey::ContentPart {
|
||
output: ev.output_index,
|
||
content: ev.content_index,
|
||
},
|
||
block_type,
|
||
);
|
||
Ok(vec![Event::BlockStart(BlockStart {
|
||
index: info.flat_index,
|
||
block_type,
|
||
metadata,
|
||
})])
|
||
}
|
||
|
||
"response.content_part.done" => {
|
||
let ev: ContentPartDone = from_json(data)?;
|
||
if let Some(info) = state.slots.remove(&SlotKey::ContentPart {
|
||
output: ev.output_index,
|
||
content: ev.content_index,
|
||
}) {
|
||
if matches!(ev.part, ContentPart::ReasoningText { .. })
|
||
|| info.block_type == BlockType::Thinking
|
||
{
|
||
state.defer_reasoning_stop(ev.output_index, info);
|
||
Ok(Vec::new())
|
||
} else {
|
||
Ok(vec![Event::BlockStop(BlockStop {
|
||
index: info.flat_index,
|
||
block_type: info.block_type,
|
||
stop_reason: None,
|
||
reasoning: None,
|
||
})])
|
||
}
|
||
} else {
|
||
Ok(Vec::new())
|
||
}
|
||
}
|
||
|
||
"response.output_text.delta" => {
|
||
let ev: OutputTextDelta = from_json(data)?;
|
||
Ok(ensure_and_delta(
|
||
state,
|
||
SlotKey::ContentPart {
|
||
output: ev.output_index,
|
||
content: ev.content_index,
|
||
},
|
||
BlockType::Text,
|
||
BlockMetadata::Text,
|
||
DeltaContent::Text(ev.delta),
|
||
))
|
||
}
|
||
|
||
"response.reasoning_text.delta" => {
|
||
let ev: ReasoningTextDelta = from_json(data)?;
|
||
// round-trip 用に蓄積
|
||
state
|
||
.ensure_reasoning(ev.output_index)
|
||
.text
|
||
.push_str(&ev.delta);
|
||
Ok(ensure_and_delta(
|
||
state,
|
||
SlotKey::ContentPart {
|
||
output: ev.output_index,
|
||
content: ev.content_index,
|
||
},
|
||
BlockType::Thinking,
|
||
BlockMetadata::Thinking,
|
||
DeltaContent::Thinking(ev.delta),
|
||
))
|
||
}
|
||
|
||
"response.reasoning_summary_part.added" => {
|
||
let ev: ReasoningSummaryPartAdded = from_json(data)?;
|
||
let info = state.allocate(
|
||
SlotKey::Summary {
|
||
output: ev.output_index,
|
||
summary: ev.summary_index,
|
||
},
|
||
BlockType::Thinking,
|
||
);
|
||
Ok(vec![Event::BlockStart(BlockStart {
|
||
index: info.flat_index,
|
||
block_type: BlockType::Thinking,
|
||
metadata: BlockMetadata::Thinking,
|
||
})])
|
||
}
|
||
|
||
"response.reasoning_summary_text.delta" => {
|
||
let ev: ReasoningSummaryTextDelta = from_json(data)?;
|
||
// round-trip 用に蓄積
|
||
state.extend_reasoning_summary(ev.output_index, ev.summary_index, &ev.delta);
|
||
Ok(ensure_and_delta(
|
||
state,
|
||
SlotKey::Summary {
|
||
output: ev.output_index,
|
||
summary: ev.summary_index,
|
||
},
|
||
BlockType::Thinking,
|
||
BlockMetadata::Thinking,
|
||
DeltaContent::Thinking(ev.delta),
|
||
))
|
||
}
|
||
|
||
"response.reasoning_summary_part.done" => {
|
||
let ev: ReasoningSummaryPartDone = from_json(data)?;
|
||
if let Some(info) = state.slots.remove(&SlotKey::Summary {
|
||
output: ev.output_index,
|
||
summary: ev.summary_index,
|
||
}) {
|
||
Ok(vec![Event::BlockStop(BlockStop {
|
||
index: info.flat_index,
|
||
block_type: info.block_type,
|
||
stop_reason: None,
|
||
reasoning: None,
|
||
})])
|
||
} else {
|
||
Ok(Vec::new())
|
||
}
|
||
}
|
||
|
||
"response.function_call_arguments.delta" => {
|
||
let ev: FunctionCallArgumentsDelta = from_json(data)?;
|
||
Ok(ensure_and_delta(
|
||
state,
|
||
SlotKey::OutputItem(ev.output_index),
|
||
BlockType::ToolUse,
|
||
BlockMetadata::ToolUse {
|
||
id: String::new(),
|
||
name: String::new(),
|
||
},
|
||
DeltaContent::InputJson(ev.delta),
|
||
))
|
||
}
|
||
|
||
"response.custom_tool_call_input.delta" => {
|
||
let ev: CustomToolCallInputDelta = from_json(data)?;
|
||
Ok(ensure_and_delta(
|
||
state,
|
||
SlotKey::OutputItem(ev.output_index),
|
||
BlockType::ToolUse,
|
||
BlockMetadata::ToolUse {
|
||
id: String::new(),
|
||
name: String::new(),
|
||
},
|
||
DeltaContent::InputJson(ev.delta),
|
||
))
|
||
}
|
||
|
||
"error" => {
|
||
let ev = from_json::<TopLevelErrorEnvelope>(data).unwrap_or_else(|_| {
|
||
TopLevelErrorEnvelope {
|
||
error: TopLevelError {
|
||
message: Some(data.to_string()),
|
||
error_type: None,
|
||
code: None,
|
||
extra: BTreeMap::new(),
|
||
},
|
||
extra: BTreeMap::new(),
|
||
}
|
||
});
|
||
let (code, message) = top_level_error_diagnostic(ev);
|
||
Ok(vec![Event::Error(ErrorEvent { code, message })])
|
||
}
|
||
|
||
// 未対応 / 情報系 event type は生成 semantics からは無視しつつ trace に残す。
|
||
_ => Ok(vec![unhandled_sse_event(event_type, data)]),
|
||
}
|
||
}
|
||
|
||
fn response_failure_diagnostic(event_type: &str, ev: ResponseFailed) -> (Option<String>, String) {
|
||
let mut diagnostic = Map::new();
|
||
diagnostic.insert("event".to_string(), Value::String(event_type.to_string()));
|
||
|
||
let mut code = None;
|
||
let base_message = if let Some(err) = ev.response.error {
|
||
code = err.code.clone().or(err.error_type.clone());
|
||
if let Some(error_type) = err.error_type {
|
||
diagnostic.insert("error_type".to_string(), Value::String(error_type));
|
||
}
|
||
if let Some(error_code) = err.code {
|
||
diagnostic.insert("error_code".to_string(), Value::String(error_code));
|
||
}
|
||
if !err.extra.is_empty() {
|
||
diagnostic.insert(
|
||
"error_extra".to_string(),
|
||
diagnostic_object(err.extra, DIAGNOSTIC_VALUE_LIMIT),
|
||
);
|
||
}
|
||
err.message
|
||
.filter(|message| !message.trim().is_empty())
|
||
.unwrap_or_else(|| format!("OpenAI Responses {event_type}"))
|
||
} else {
|
||
format!("OpenAI Responses {event_type}")
|
||
};
|
||
|
||
let response_extra = ev.response.extra;
|
||
if let Some(reason) = response_extra
|
||
.get("incomplete_details")
|
||
.and_then(|value| value.get("reason"))
|
||
.and_then(Value::as_str)
|
||
{
|
||
diagnostic.insert(
|
||
"incomplete_reason".to_string(),
|
||
Value::String(reason.to_string()),
|
||
);
|
||
if code.is_none() {
|
||
code = Some(reason.to_string());
|
||
}
|
||
}
|
||
if !response_extra.is_empty() {
|
||
diagnostic.insert(
|
||
"response_extra".to_string(),
|
||
diagnostic_object(response_extra, DIAGNOSTIC_VALUE_LIMIT),
|
||
);
|
||
}
|
||
if !ev.extra.is_empty() {
|
||
diagnostic.insert(
|
||
"event_extra".to_string(),
|
||
diagnostic_object(ev.extra, DIAGNOSTIC_VALUE_LIMIT),
|
||
);
|
||
}
|
||
|
||
(code, append_diagnostic(base_message, diagnostic))
|
||
}
|
||
|
||
fn top_level_error_diagnostic(ev: TopLevelErrorEnvelope) -> (Option<String>, String) {
|
||
let code = ev.error.code.clone().or(ev.error.error_type.clone());
|
||
let mut diagnostic = Map::new();
|
||
diagnostic.insert("event".to_string(), Value::String("error".to_string()));
|
||
if let Some(error_type) = ev.error.error_type {
|
||
diagnostic.insert("error_type".to_string(), Value::String(error_type));
|
||
}
|
||
if let Some(error_code) = ev.error.code {
|
||
diagnostic.insert("error_code".to_string(), Value::String(error_code));
|
||
}
|
||
if !ev.error.extra.is_empty() {
|
||
diagnostic.insert(
|
||
"error_extra".to_string(),
|
||
diagnostic_object(ev.error.extra, DIAGNOSTIC_VALUE_LIMIT),
|
||
);
|
||
}
|
||
if !ev.extra.is_empty() {
|
||
diagnostic.insert(
|
||
"event_extra".to_string(),
|
||
diagnostic_object(ev.extra, DIAGNOSTIC_VALUE_LIMIT),
|
||
);
|
||
}
|
||
|
||
let message = ev
|
||
.error
|
||
.message
|
||
.filter(|message| !message.trim().is_empty())
|
||
.unwrap_or_else(|| "OpenAI Responses error".to_string());
|
||
(code, append_diagnostic(message, diagnostic))
|
||
}
|
||
|
||
const DIAGNOSTIC_VALUE_LIMIT: usize = 512;
|
||
const UNHANDLED_SSE_DATA_PREVIEW_LIMIT: usize = 512;
|
||
|
||
fn capped_unhandled_sse_data_preview(data: &str) -> String {
|
||
if data.len() <= UNHANDLED_SSE_DATA_PREVIEW_LIMIT {
|
||
return data.to_string();
|
||
}
|
||
|
||
let mut end = 0;
|
||
for (idx, ch) in data.char_indices() {
|
||
let next = idx + ch.len_utf8();
|
||
if next > UNHANDLED_SSE_DATA_PREVIEW_LIMIT {
|
||
break;
|
||
}
|
||
end = next;
|
||
}
|
||
data[..end].to_string()
|
||
}
|
||
|
||
fn unhandled_sse_event(event_type: &str, data: &str) -> Event {
|
||
Event::UnhandledSse(UnhandledSseEvent {
|
||
provider: "openai_responses".to_string(),
|
||
event_type: event_type.to_string(),
|
||
data_preview: capped_unhandled_sse_data_preview(data),
|
||
data_len: data.len(),
|
||
})
|
||
}
|
||
|
||
fn diagnostic_object(extra: BTreeMap<String, Value>, value_limit: usize) -> Value {
|
||
Value::Object(
|
||
extra
|
||
.into_iter()
|
||
.map(|(key, value)| (key, cap_json_value(value, value_limit)))
|
||
.collect(),
|
||
)
|
||
}
|
||
|
||
fn cap_json_value(value: Value, limit: usize) -> Value {
|
||
let rendered = value.to_string();
|
||
if rendered.len() <= limit {
|
||
value
|
||
} else {
|
||
let capped: String = rendered.chars().take(limit).collect();
|
||
Value::String(format!("{capped}…"))
|
||
}
|
||
}
|
||
|
||
fn append_diagnostic(message: String, diagnostic: Map<String, Value>) -> String {
|
||
if diagnostic.len() <= 1 {
|
||
return message;
|
||
}
|
||
format!("{} | diagnostic={}", message, Value::Object(diagnostic))
|
||
}
|
||
|
||
/// 対応する BlockStart がまだ発行されていなければ発行しつつ、delta を流す。
|
||
/// content_part.added を取りこぼしても delta 単独で復旧できるようにする。
|
||
fn ensure_and_delta(
|
||
state: &mut OpenAIResponsesState,
|
||
key: SlotKey,
|
||
block_type: BlockType,
|
||
metadata: BlockMetadata,
|
||
delta: DeltaContent,
|
||
) -> Vec<Event> {
|
||
let (info, just_created) = state.get_or_allocate(key, block_type);
|
||
let mut out = Vec::with_capacity(2);
|
||
if just_created {
|
||
out.push(Event::BlockStart(BlockStart {
|
||
index: info.flat_index,
|
||
block_type,
|
||
metadata,
|
||
}));
|
||
}
|
||
out.push(Event::BlockDelta(BlockDelta {
|
||
index: info.flat_index,
|
||
delta,
|
||
}));
|
||
out
|
||
}
|
||
|
||
fn from_json<T: for<'de> Deserialize<'de>>(data: &str) -> Result<T, ClientError> {
|
||
serde_json::from_str(data).map_err(|e| ClientError::Api {
|
||
status: None,
|
||
code: Some("parse_error".to_string()),
|
||
message: format!("Failed to parse SSE data: {e}"),
|
||
retry_after: None,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn run(event_type: &str, data: &str) -> (Vec<Event>, OpenAIResponsesState) {
|
||
let mut state = OpenAIResponsesState::default();
|
||
let events = parse_sse(event_type, data, &mut state).unwrap();
|
||
(events, state)
|
||
}
|
||
|
||
fn with(state: &mut OpenAIResponsesState, event_type: &str, data: &str) -> Vec<Event> {
|
||
parse_sse(event_type, data, state).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn created_emits_status_started() {
|
||
let (events, _) = run("response.created", r#"{"response":{}}"#);
|
||
assert!(matches!(
|
||
events[0],
|
||
Event::Status(StatusEvent {
|
||
status: ResponseStatus::Started
|
||
})
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn completed_emits_usage_and_status() {
|
||
let data =
|
||
r#"{"response":{"usage":{"input_tokens":10,"output_tokens":20,"total_tokens":30}}}"#;
|
||
let (events, _) = run("response.completed", data);
|
||
assert!(matches!(events[0], Event::Usage(_)));
|
||
assert!(matches!(
|
||
events[1],
|
||
Event::Status(StatusEvent {
|
||
status: ResponseStatus::Completed
|
||
})
|
||
));
|
||
if let Event::Usage(u) = &events[0] {
|
||
assert_eq!(u.input_tokens, Some(10));
|
||
assert_eq!(u.output_tokens, Some(20));
|
||
assert_eq!(u.total_tokens, Some(30));
|
||
assert_eq!(u.cache_read_input_tokens, None);
|
||
assert_eq!(u.cache_creation_input_tokens, None);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn completed_extracts_cached_tokens_from_input_tokens_details() {
|
||
let data = r#"{"response":{"usage":{
|
||
"input_tokens":12345,
|
||
"input_tokens_details":{"cached_tokens":11000},
|
||
"output_tokens":50,
|
||
"total_tokens":12395
|
||
}}}"#;
|
||
let (events, _) = run("response.completed", data);
|
||
let Event::Usage(u) = &events[0] else {
|
||
panic!("expected usage")
|
||
};
|
||
assert_eq!(u.input_tokens, Some(12345));
|
||
assert_eq!(u.output_tokens, Some(50));
|
||
assert_eq!(u.total_tokens, Some(12395));
|
||
assert_eq!(u.cache_read_input_tokens, Some(11000));
|
||
// OpenAI Responses は cache 書き込みを別計上しない
|
||
assert_eq!(u.cache_creation_input_tokens, None);
|
||
}
|
||
|
||
#[test]
|
||
fn text_stream_start_delta_stop() {
|
||
let mut state = OpenAIResponsesState::default();
|
||
// output_item.added (message) → 無視
|
||
with(
|
||
&mut state,
|
||
"response.output_item.added",
|
||
r#"{"output_index":0,"item":{"type":"message","id":"m1"}}"#,
|
||
);
|
||
// content_part.added (output_text) → BlockStart(Text)
|
||
let ev = with(
|
||
&mut state,
|
||
"response.content_part.added",
|
||
r#"{"output_index":0,"content_index":0,"item_id":"m1","part":{"type":"output_text","text":""}}"#,
|
||
);
|
||
assert_eq!(ev.len(), 1);
|
||
assert!(matches!(ev[0], Event::BlockStart(_)));
|
||
// delta
|
||
let ev = with(
|
||
&mut state,
|
||
"response.output_text.delta",
|
||
r#"{"output_index":0,"content_index":0,"item_id":"m1","delta":"hi"}"#,
|
||
);
|
||
assert_eq!(ev.len(), 1);
|
||
if let Event::BlockDelta(d) = &ev[0] {
|
||
assert!(matches!(&d.delta, DeltaContent::Text(t) if t == "hi"));
|
||
} else {
|
||
panic!("expected delta");
|
||
}
|
||
// content_part.done → BlockStop
|
||
let ev = with(
|
||
&mut state,
|
||
"response.content_part.done",
|
||
r#"{"output_index":0,"content_index":0,"item_id":"m1","part":{"type":"output_text","text":"hi"}}"#,
|
||
);
|
||
assert_eq!(ev.len(), 1);
|
||
if let Event::BlockStop(s) = &ev[0] {
|
||
assert_eq!(s.block_type, BlockType::Text);
|
||
} else {
|
||
panic!("expected stop");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn function_call_start_delta_stop() {
|
||
let mut state = OpenAIResponsesState::default();
|
||
// output_item.added (function_call) → BlockStart(ToolUse, id, name)
|
||
let ev = with(
|
||
&mut state,
|
||
"response.output_item.added",
|
||
r#"{"output_index":1,"item":{"type":"function_call","id":"fc1","call_id":"call_abc","name":"get_weather"}}"#,
|
||
);
|
||
assert_eq!(ev.len(), 1);
|
||
if let Event::BlockStart(s) = &ev[0] {
|
||
assert_eq!(s.block_type, BlockType::ToolUse);
|
||
if let BlockMetadata::ToolUse { id, name } = &s.metadata {
|
||
assert_eq!(id, "call_abc");
|
||
assert_eq!(name, "get_weather");
|
||
} else {
|
||
panic!("expected ToolUse metadata");
|
||
}
|
||
} else {
|
||
panic!("expected BlockStart");
|
||
}
|
||
// arguments delta
|
||
let ev = with(
|
||
&mut state,
|
||
"response.function_call_arguments.delta",
|
||
r#"{"output_index":1,"item_id":"fc1","delta":"{\"x\":"}"#,
|
||
);
|
||
assert_eq!(ev.len(), 1);
|
||
if let Event::BlockDelta(d) = &ev[0] {
|
||
assert!(matches!(&d.delta, DeltaContent::InputJson(j) if j == "{\"x\":"));
|
||
}
|
||
// output_item.done → BlockStop
|
||
let ev = with(
|
||
&mut state,
|
||
"response.output_item.done",
|
||
r#"{"output_index":1,"item":{"type":"function_call","call_id":"call_abc","name":"get_weather","arguments":"{\"x\":1}"}}"#,
|
||
);
|
||
assert_eq!(ev.len(), 1);
|
||
assert!(matches!(ev[0], Event::BlockStop(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn custom_tool_call_input_delta_parsed() {
|
||
let mut state = OpenAIResponsesState::default();
|
||
with(
|
||
&mut state,
|
||
"response.output_item.added",
|
||
r#"{"output_index":0,"item":{"type":"custom_tool_call","id":"ct1","call_id":"call_xyz","name":"custom"}}"#,
|
||
);
|
||
let ev = with(
|
||
&mut state,
|
||
"response.custom_tool_call_input.delta",
|
||
r#"{"output_index":0,"item_id":"ct1","delta":"raw"}"#,
|
||
);
|
||
assert_eq!(ev.len(), 1);
|
||
if let Event::BlockDelta(d) = &ev[0] {
|
||
assert!(matches!(&d.delta, DeltaContent::InputJson(j) if j == "raw"));
|
||
} else {
|
||
panic!("expected delta");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn reasoning_text_delta_emits_thinking() {
|
||
let mut state = OpenAIResponsesState::default();
|
||
with(
|
||
&mut state,
|
||
"response.content_part.added",
|
||
r#"{"output_index":0,"content_index":0,"item_id":"r1","part":{"type":"reasoning_text","text":""}}"#,
|
||
);
|
||
let ev = with(
|
||
&mut state,
|
||
"response.reasoning_text.delta",
|
||
r#"{"output_index":0,"content_index":0,"item_id":"r1","delta":"think"}"#,
|
||
);
|
||
if let Event::BlockDelta(d) = &ev[0] {
|
||
assert!(matches!(&d.delta, DeltaContent::Thinking(t) if t == "think"));
|
||
} else {
|
||
panic!("expected thinking delta");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn reasoning_summary_start_delta_stop() {
|
||
let mut state = OpenAIResponsesState::default();
|
||
let ev = with(
|
||
&mut state,
|
||
"response.reasoning_summary_part.added",
|
||
r#"{"output_index":0,"summary_index":0,"item_id":"r1","part":{"type":"summary_text","text":""}}"#,
|
||
);
|
||
assert!(matches!(ev[0], Event::BlockStart(_)));
|
||
let ev = with(
|
||
&mut state,
|
||
"response.reasoning_summary_text.delta",
|
||
r#"{"output_index":0,"summary_index":0,"item_id":"r1","delta":"sum"}"#,
|
||
);
|
||
if let Event::BlockDelta(d) = &ev[0] {
|
||
assert!(matches!(&d.delta, DeltaContent::Thinking(t) if t == "sum"));
|
||
}
|
||
let ev = with(
|
||
&mut state,
|
||
"response.reasoning_summary_part.done",
|
||
r#"{"output_index":0,"summary_index":0,"item_id":"r1"}"#,
|
||
);
|
||
assert!(matches!(ev[0], Event::BlockStop(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn delta_without_prior_start_recovers() {
|
||
// 防御: content_part.added が落ちても delta 単独で BlockStart+Delta を発行
|
||
let mut state = OpenAIResponsesState::default();
|
||
let ev = with(
|
||
&mut state,
|
||
"response.output_text.delta",
|
||
r#"{"output_index":0,"content_index":0,"item_id":"m1","delta":"hi"}"#,
|
||
);
|
||
assert_eq!(ev.len(), 2);
|
||
assert!(matches!(ev[0], Event::BlockStart(_)));
|
||
assert!(matches!(ev[1], Event::BlockDelta(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn parallel_output_items_get_distinct_indices() {
|
||
// 2 つの function_call が並列で output_item.added される場合、
|
||
// flat index が別々になる(Parallel tool calling の基本)。
|
||
let mut state = OpenAIResponsesState::default();
|
||
let ev1 = with(
|
||
&mut state,
|
||
"response.output_item.added",
|
||
r#"{"output_index":0,"item":{"type":"function_call","id":"a","call_id":"c1","name":"t1"}}"#,
|
||
);
|
||
let ev2 = with(
|
||
&mut state,
|
||
"response.output_item.added",
|
||
r#"{"output_index":1,"item":{"type":"function_call","id":"b","call_id":"c2","name":"t2"}}"#,
|
||
);
|
||
let i1 = if let Event::BlockStart(s) = &ev1[0] {
|
||
s.index
|
||
} else {
|
||
panic!()
|
||
};
|
||
let i2 = if let Event::BlockStart(s) = &ev2[0] {
|
||
s.index
|
||
} else {
|
||
panic!()
|
||
};
|
||
assert_ne!(i1, i2);
|
||
}
|
||
|
||
#[test]
|
||
fn failed_response_emits_error_and_status() {
|
||
let data = r#"{"response":{"error":{"type":"invalid_request_error","message":"bad"}}}"#;
|
||
let (events, _) = run("response.failed", data);
|
||
assert_eq!(events.len(), 2);
|
||
assert!(matches!(events[0], Event::Error(_)));
|
||
assert!(matches!(
|
||
events[1],
|
||
Event::Status(StatusEvent {
|
||
status: ResponseStatus::Failed
|
||
})
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn incomplete_response_preserves_incomplete_reason_without_error() {
|
||
let data = r#"{
|
||
"response": {
|
||
"status": "incomplete",
|
||
"incomplete_details": {"reason": "max_output_tokens"}
|
||
}
|
||
}"#;
|
||
let (events, _) = run("response.incomplete", data);
|
||
let Event::Error(err) = &events[0] else {
|
||
panic!("expected error event")
|
||
};
|
||
assert_eq!(err.code.as_deref(), Some("max_output_tokens"));
|
||
assert!(err.message.contains("OpenAI Responses response.incomplete"));
|
||
assert!(err.message.contains("incomplete_reason"));
|
||
assert!(err.message.contains("max_output_tokens"));
|
||
assert!(!err.message.ends_with("response response.incomplete"));
|
||
}
|
||
|
||
#[test]
|
||
fn incomplete_response_preserves_unknown_response_fields() {
|
||
let data = r#"{
|
||
"response": {
|
||
"status": "incomplete",
|
||
"incomplete_details": {"reason": "content_filter"},
|
||
"mystery_field": {"nested": true}
|
||
},
|
||
"sequence_number": 42
|
||
}"#;
|
||
let (events, _) = run("response.incomplete", data);
|
||
let Event::Error(err) = &events[0] else {
|
||
panic!("expected error event")
|
||
};
|
||
assert!(err.message.contains("mystery_field"));
|
||
assert!(err.message.contains("sequence_number"));
|
||
assert!(err.message.contains("content_filter"));
|
||
}
|
||
|
||
#[test]
|
||
fn failed_response_preserves_error_and_response_extra_fields() {
|
||
let data = r#"{
|
||
"response": {
|
||
"error": {
|
||
"type": "server_error",
|
||
"code": "upstream_overloaded",
|
||
"message": "try later",
|
||
"param": "input"
|
||
},
|
||
"retry_hint": "short"
|
||
}
|
||
}"#;
|
||
let (events, _) = run("response.failed", data);
|
||
let Event::Error(err) = &events[0] else {
|
||
panic!("expected error event")
|
||
};
|
||
assert_eq!(err.code.as_deref(), Some("upstream_overloaded"));
|
||
assert!(err.message.contains("try later"));
|
||
assert!(err.message.contains("param"));
|
||
assert!(err.message.contains("retry_hint"));
|
||
}
|
||
|
||
#[test]
|
||
fn top_level_error_preserves_unknown_fields() {
|
||
let data = r#"{
|
||
"error": {
|
||
"type": "rate_limit_error",
|
||
"code": "rate_limit_exceeded",
|
||
"message": "slow down",
|
||
"retry_after_ms": 1000
|
||
},
|
||
"request_id": "req_123"
|
||
}"#;
|
||
let (events, _) = run("error", data);
|
||
let Event::Error(err) = &events[0] else {
|
||
panic!("expected error event")
|
||
};
|
||
assert_eq!(err.code.as_deref(), Some("rate_limit_exceeded"));
|
||
assert!(err.message.contains("slow down"));
|
||
assert!(err.message.contains("retry_after_ms"));
|
||
assert!(err.message.contains("request_id"));
|
||
}
|
||
|
||
#[test]
|
||
fn reasoning_output_item_completes_metadata_thinking_block_with_text_summary_encrypted() {
|
||
// 完成済み reasoning wrapper が text + summary[] + encrypted_content を持って
|
||
// Thinking BlockStop metadata として届くこと。
|
||
let mut state = OpenAIResponsesState::default();
|
||
|
||
// wrapper added (id だけ持つ)
|
||
with(
|
||
&mut state,
|
||
"response.output_item.added",
|
||
r#"{"output_index":0,"item":{"type":"reasoning","id":"r1"}}"#,
|
||
);
|
||
// 内側の reasoning_text 用 content_part
|
||
let start = with(
|
||
&mut state,
|
||
"response.content_part.added",
|
||
r#"{"output_index":0,"content_index":0,"item_id":"r1","part":{"type":"reasoning_text","text":""}}"#,
|
||
);
|
||
let start_index = match start.as_slice() {
|
||
[Event::BlockStart(start)] => start.index,
|
||
other => panic!("expected one BlockStart, got {other:?}"),
|
||
};
|
||
with(
|
||
&mut state,
|
||
"response.reasoning_text.delta",
|
||
r#"{"output_index":0,"content_index":0,"item_id":"r1","delta":"hello "}"#,
|
||
);
|
||
with(
|
||
&mut state,
|
||
"response.reasoning_text.delta",
|
||
r#"{"output_index":0,"content_index":0,"item_id":"r1","delta":"world"}"#,
|
||
);
|
||
let part_done = with(
|
||
&mut state,
|
||
"response.content_part.done",
|
||
r#"{"output_index":0,"content_index":0,"item_id":"r1","part":{"type":"reasoning_text","text":"hello world"}}"#,
|
||
);
|
||
assert!(part_done.is_empty());
|
||
// summary 1 件
|
||
with(
|
||
&mut state,
|
||
"response.reasoning_summary_part.added",
|
||
r#"{"output_index":0,"summary_index":0,"item_id":"r1","part":{"type":"summary_text","text":""}}"#,
|
||
);
|
||
with(
|
||
&mut state,
|
||
"response.reasoning_summary_text.delta",
|
||
r#"{"output_index":0,"summary_index":0,"item_id":"r1","delta":"sum-A"}"#,
|
||
);
|
||
with(
|
||
&mut state,
|
||
"response.reasoning_summary_part.done",
|
||
r#"{"output_index":0,"summary_index":0,"item_id":"r1"}"#,
|
||
);
|
||
|
||
// wrapper done (encrypted_content が乗る)
|
||
let evs = with(
|
||
&mut state,
|
||
"response.output_item.done",
|
||
r#"{"output_index":0,"item":{"type":"reasoning","id":"r1","encrypted_content":"ENC-XYZ"}}"#,
|
||
);
|
||
assert_eq!(evs.len(), 1);
|
||
let Event::BlockStop(stop) = &evs[0] else {
|
||
panic!("expected BlockStop, got {:?}", evs[0]);
|
||
};
|
||
assert_eq!(stop.index, start_index);
|
||
let reasoning = stop.reasoning.as_ref().expect("reasoning metadata");
|
||
assert_eq!(reasoning.id.as_deref(), Some("r1"));
|
||
assert_eq!(reasoning.text.as_deref(), Some("hello world"));
|
||
assert_eq!(reasoning.summary, vec!["sum-A".to_string()]);
|
||
assert_eq!(reasoning.encrypted_content.as_deref(), Some("ENC-XYZ"));
|
||
assert!(reasoning.signature.is_none());
|
||
// pending_reasoning は drain されていること
|
||
assert!(state.pending_reasoning.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn reasoning_text_done_then_output_done_emits_single_existing_block_stop() {
|
||
let mut state = OpenAIResponsesState::default();
|
||
with(
|
||
&mut state,
|
||
"response.output_item.added",
|
||
r#"{"output_index":0,"item":{"type":"reasoning","id":"r1"}}"#,
|
||
);
|
||
|
||
let mut lifecycle = Vec::new();
|
||
lifecycle.extend(with(
|
||
&mut state,
|
||
"response.content_part.added",
|
||
r#"{"output_index":0,"content_index":0,"item_id":"r1","part":{"type":"reasoning_text","text":""}}"#,
|
||
));
|
||
lifecycle.extend(with(
|
||
&mut state,
|
||
"response.reasoning_text.delta",
|
||
r#"{"output_index":0,"content_index":0,"item_id":"r1","delta":"think"}"#,
|
||
));
|
||
lifecycle.extend(with(
|
||
&mut state,
|
||
"response.content_part.done",
|
||
r#"{"output_index":0,"content_index":0,"item_id":"r1","part":{"type":"reasoning_text","text":"think"}}"#,
|
||
));
|
||
lifecycle.extend(with(
|
||
&mut state,
|
||
"response.output_item.done",
|
||
r#"{"output_index":0,"item":{"type":"reasoning","id":"r1","encrypted_content":"ENC"}}"#,
|
||
));
|
||
|
||
let starts: Vec<_> = lifecycle
|
||
.iter()
|
||
.filter_map(|event| match event {
|
||
Event::BlockStart(start) => Some(start.index),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let stops: Vec<_> = lifecycle
|
||
.iter()
|
||
.filter_map(|event| match event {
|
||
Event::BlockStop(stop) => Some(stop),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
|
||
assert_eq!(starts.len(), 1, "no synthetic second Thinking start");
|
||
assert_eq!(stops.len(), 1, "no duplicate empty Thinking stop");
|
||
assert_eq!(stops[0].index, starts[0]);
|
||
let reasoning = stops[0].reasoning.as_ref().expect("reasoning metadata");
|
||
assert_eq!(reasoning.text.as_deref(), Some("think"));
|
||
assert_eq!(reasoning.encrypted_content.as_deref(), Some("ENC"));
|
||
|
||
struct StopRecorder(std::sync::Arc<std::sync::Mutex<Vec<(usize, String, bool)>>>);
|
||
impl crate::handler::Handler<crate::handler::ThinkingBlockKind> for StopRecorder {
|
||
type Scope = String;
|
||
|
||
fn on_event(
|
||
&mut self,
|
||
scope: &mut Self::Scope,
|
||
event: &crate::handler::ThinkingBlockEvent,
|
||
) {
|
||
match event {
|
||
crate::handler::ThinkingBlockEvent::Start(_) => scope.clear(),
|
||
crate::handler::ThinkingBlockEvent::Delta(delta) => scope.push_str(delta),
|
||
crate::handler::ThinkingBlockEvent::Stop(stop) => self
|
||
.0
|
||
.lock()
|
||
.unwrap()
|
||
.push((stop.index, scope.clone(), stop.reasoning.is_some())),
|
||
}
|
||
}
|
||
}
|
||
|
||
let stops = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||
let mut timeline = crate::timeline::Timeline::new();
|
||
timeline.on_thinking_block(StopRecorder(stops.clone()));
|
||
for event in &lifecycle {
|
||
timeline.dispatch(event);
|
||
}
|
||
let stops = stops.lock().unwrap().clone();
|
||
assert_eq!(stops, vec![(starts[0], "think".to_string(), true)]);
|
||
}
|
||
|
||
#[test]
|
||
fn reasoning_wrapper_without_inner_content_emits_empty_text() {
|
||
// encrypted_content だけ届く(reasoning_text 無し)ケースでも
|
||
// reasoning metadata は届く。
|
||
let mut state = OpenAIResponsesState::default();
|
||
with(
|
||
&mut state,
|
||
"response.output_item.added",
|
||
r#"{"output_index":2,"item":{"type":"reasoning","id":"r9"}}"#,
|
||
);
|
||
let evs = with(
|
||
&mut state,
|
||
"response.output_item.done",
|
||
r#"{"output_index":2,"item":{"type":"reasoning","id":"r9","encrypted_content":"BLOB"}}"#,
|
||
);
|
||
assert_eq!(evs.len(), 2);
|
||
assert!(matches!(evs[0], Event::BlockStart(_)));
|
||
let Event::BlockStop(stop) = &evs[1] else {
|
||
panic!("expected BlockStop")
|
||
};
|
||
let r = stop.reasoning.as_ref().expect("reasoning metadata");
|
||
assert_eq!(r.text.as_deref(), Some(""));
|
||
assert!(r.summary.is_empty());
|
||
assert_eq!(r.encrypted_content.as_deref(), Some("BLOB"));
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_event_emits_trace_visible_unhandled_sse() {
|
||
let data = r#"{"sequence_number":7,"note":"debug me"}"#;
|
||
let (events, _) = run("response.mystery", data);
|
||
assert_eq!(events.len(), 1);
|
||
let Event::UnhandledSse(unhandled) = &events[0] else {
|
||
panic!("expected UnhandledSse, got {:?}", events[0]);
|
||
};
|
||
assert_eq!(unhandled.provider, "openai_responses");
|
||
assert_eq!(unhandled.event_type, "response.mystery");
|
||
assert_eq!(unhandled.data_preview, data);
|
||
assert_eq!(unhandled.data_len, data.len());
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_event_data_preview_is_bounded_and_data_len_is_original_bytes() {
|
||
let data = format!("{}終端", "x".repeat(UNHANDLED_SSE_DATA_PREVIEW_LIMIT + 32));
|
||
let (events, _) = run("response.mystery.large", &data);
|
||
assert_eq!(events.len(), 1);
|
||
let Event::UnhandledSse(unhandled) = &events[0] else {
|
||
panic!("expected UnhandledSse, got {:?}", events[0]);
|
||
};
|
||
assert_eq!(unhandled.data_len, data.len());
|
||
assert!(unhandled.data_preview.len() <= UNHANDLED_SSE_DATA_PREVIEW_LIMIT);
|
||
assert_eq!(
|
||
unhandled.data_preview,
|
||
"x".repeat(UNHANDLED_SSE_DATA_PREVIEW_LIMIT)
|
||
);
|
||
assert!(unhandled.data_preview.len() < unhandled.data_len);
|
||
}
|
||
}
|