mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
3947 lines
148 KiB
Rust
3947 lines
148 KiB
Rust
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
use serde_json::{json, Map, Value};
|
|
|
|
use crate::formats::shared::response::build_generated_tool_call_id;
|
|
use crate::formats::shared::sse::{encode_done_sse, encode_json_sse};
|
|
use crate::formats::shared::stream_core::common::*;
|
|
use crate::formats::shared::AiSurfaceFinalizeError;
|
|
|
|
#[derive(Default)]
|
|
struct OpenAIChatProviderToolState {
|
|
id: Option<String>,
|
|
name: Option<String>,
|
|
started_emitted: bool,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct OpenAIChatProviderState {
|
|
response_id: Option<String>,
|
|
model: Option<String>,
|
|
started: bool,
|
|
finished: bool,
|
|
pending_finish_reason: Option<String>,
|
|
tool_calls: BTreeMap<usize, OpenAIChatProviderToolState>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct OpenAIResponsesProviderToolState {
|
|
call_id: String,
|
|
name: String,
|
|
arguments: String,
|
|
started_emitted: bool,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct OpenAIResponsesProviderToolResultState {
|
|
content: String,
|
|
emitted: bool,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct OpenAIResponsesProviderState {
|
|
response_id: Option<String>,
|
|
model: Option<String>,
|
|
started: bool,
|
|
finished: bool,
|
|
text: String,
|
|
reasoning: String,
|
|
reasoning_parts: BTreeMap<usize, String>,
|
|
tool_calls: BTreeMap<usize, OpenAIResponsesProviderToolState>,
|
|
tool_results: BTreeMap<usize, OpenAIResponsesProviderToolResultState>,
|
|
tool_index_by_key: BTreeMap<String, usize>,
|
|
image_item_keys: BTreeSet<String>,
|
|
last_tool_index: Option<usize>,
|
|
}
|
|
|
|
impl OpenAIChatProviderState {
|
|
fn finish_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
|
let usage_object = value?.as_object()?;
|
|
let has_token_fields = [
|
|
"input_tokens",
|
|
"prompt_tokens",
|
|
"output_tokens",
|
|
"completion_tokens",
|
|
"total_tokens",
|
|
]
|
|
.iter()
|
|
.any(|key| usage_object.contains_key(*key));
|
|
if !has_token_fields {
|
|
return None;
|
|
}
|
|
canonical_usage_from_openai_usage(value)
|
|
}
|
|
|
|
fn identity(&self, report_context: &Value) -> (String, String) {
|
|
resolve_identity(
|
|
self.response_id.as_deref(),
|
|
self.model.as_deref(),
|
|
report_context,
|
|
"chatcmpl-local-stream",
|
|
)
|
|
}
|
|
|
|
fn ensure_started(&mut self, report_context: &Value, out: &mut Vec<CanonicalStreamFrame>) {
|
|
if self.started {
|
|
return;
|
|
}
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::Start,
|
|
});
|
|
self.started = true;
|
|
}
|
|
|
|
fn unknown_frame(&self, report_context: &Value, payload: Value) -> CanonicalStreamFrame {
|
|
let (id, model) = self.identity(report_context);
|
|
CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::UnknownEvent(payload),
|
|
}
|
|
}
|
|
|
|
pub fn push_line(
|
|
&mut self,
|
|
report_context: &Value,
|
|
line: Vec<u8>,
|
|
) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError> {
|
|
let Some(value) = decode_json_data_line(&line) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let Some(chunk_object) = value.as_object() else {
|
|
return Ok(Vec::new());
|
|
};
|
|
self.response_id = chunk_object
|
|
.get("id")
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| self.response_id.clone());
|
|
self.model = chunk_object
|
|
.get("model")
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| self.model.clone());
|
|
|
|
let mut out = Vec::new();
|
|
let Some(chunk_choices) = chunk_object.get("choices").and_then(Value::as_array) else {
|
|
if let Some(usage) = Self::finish_usage(chunk_object.get("usage")) {
|
|
self.ensure_started(report_context, &mut out);
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason: self.pending_finish_reason.take(),
|
|
usage: Some(usage),
|
|
},
|
|
});
|
|
self.finished = true;
|
|
} else if chunk_object.contains_key("choices")
|
|
|| chunk_object
|
|
.get("object")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|object| object.contains("chat.completion"))
|
|
{
|
|
out.push(self.unknown_frame(report_context, value.clone()));
|
|
}
|
|
return Ok(out);
|
|
};
|
|
if chunk_choices.is_empty() {
|
|
if let (Some(finish_reason), Some(usage)) = (
|
|
self.pending_finish_reason.take(),
|
|
Self::finish_usage(chunk_object.get("usage")),
|
|
) {
|
|
self.ensure_started(report_context, &mut out);
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason: Some(finish_reason),
|
|
usage: Some(usage),
|
|
},
|
|
});
|
|
self.finished = true;
|
|
}
|
|
return Ok(out);
|
|
}
|
|
for chunk_choice in chunk_choices {
|
|
let Some(choice_object) = chunk_choice.as_object() else {
|
|
out.push(self.unknown_frame(report_context, chunk_choice.clone()));
|
|
continue;
|
|
};
|
|
let finish_reason_key_present = choice_object.contains_key("finish_reason");
|
|
let Some(delta) = choice_object.get("delta").and_then(Value::as_object) else {
|
|
if let Some(finish_reason) = normalize_openai_finish_reason(
|
|
choice_object.get("finish_reason").and_then(Value::as_str),
|
|
) {
|
|
if let Some(usage) = Self::finish_usage(chunk_object.get("usage")) {
|
|
self.ensure_started(report_context, &mut out);
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason: Some(finish_reason),
|
|
usage: Some(usage),
|
|
},
|
|
});
|
|
self.finished = true;
|
|
} else {
|
|
self.pending_finish_reason = Some(finish_reason);
|
|
}
|
|
} else if !finish_reason_key_present {
|
|
out.push(
|
|
self.unknown_frame(report_context, Value::Object(choice_object.clone())),
|
|
);
|
|
}
|
|
continue;
|
|
};
|
|
|
|
let mut recognized_delta = false;
|
|
if delta.get("role").and_then(Value::as_str) == Some("assistant") {
|
|
recognized_delta = true;
|
|
self.ensure_started(report_context, &mut out);
|
|
} else if delta.contains_key("role") {
|
|
recognized_delta = true;
|
|
}
|
|
|
|
if let Some(content) = delta.get("content").and_then(Value::as_str) {
|
|
recognized_delta = true;
|
|
if !content.is_empty() {
|
|
self.ensure_started(report_context, &mut out);
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::TextDelta(content.to_string()),
|
|
});
|
|
}
|
|
} else if delta.contains_key("content") {
|
|
recognized_delta = true;
|
|
}
|
|
if let Some(reasoning_content) = delta.get("reasoning_content").and_then(Value::as_str)
|
|
{
|
|
recognized_delta = true;
|
|
if !reasoning_content.is_empty() {
|
|
self.ensure_started(report_context, &mut out);
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::ReasoningDelta(reasoning_content.to_string()),
|
|
});
|
|
}
|
|
} else if delta.contains_key("reasoning_content") {
|
|
recognized_delta = true;
|
|
}
|
|
|
|
if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
|
|
recognized_delta = true;
|
|
self.ensure_started(report_context, &mut out);
|
|
let (id, model) = self.identity(report_context);
|
|
for tool_call in tool_calls {
|
|
let Some(tool_call_object) = tool_call.as_object() else {
|
|
continue;
|
|
};
|
|
let index = tool_call_object
|
|
.get("index")
|
|
.and_then(Value::as_u64)
|
|
.map(|value| value as usize)
|
|
.unwrap_or(0);
|
|
let state = self.tool_calls.entry(index).or_default();
|
|
if let Some(call_id) = tool_call_object.get("id").and_then(Value::as_str) {
|
|
state.id = Some(call_id.to_string());
|
|
}
|
|
if let Some(function) =
|
|
tool_call_object.get("function").and_then(Value::as_object)
|
|
{
|
|
if let Some(name) = function.get("name").and_then(Value::as_str) {
|
|
state.name = Some(name.to_string());
|
|
}
|
|
if !state.started_emitted && (state.id.is_some() || state.name.is_some()) {
|
|
out.push(CanonicalStreamFrame {
|
|
id: id.clone(),
|
|
model: model.clone(),
|
|
event: CanonicalStreamEvent::ToolCallStart {
|
|
index,
|
|
call_id: state
|
|
.id
|
|
.clone()
|
|
.unwrap_or_else(|| build_generated_tool_call_id(index)),
|
|
name: state
|
|
.name
|
|
.clone()
|
|
.unwrap_or_else(|| "unknown".to_string()),
|
|
},
|
|
});
|
|
state.started_emitted = true;
|
|
}
|
|
if let Some(arguments) = function.get("arguments").and_then(Value::as_str) {
|
|
if !arguments.is_empty() {
|
|
if !state.started_emitted {
|
|
out.push(CanonicalStreamFrame {
|
|
id: id.clone(),
|
|
model: model.clone(),
|
|
event: CanonicalStreamEvent::ToolCallStart {
|
|
index,
|
|
call_id: state.id.clone().unwrap_or_else(|| {
|
|
build_generated_tool_call_id(index)
|
|
}),
|
|
name: state
|
|
.name
|
|
.clone()
|
|
.unwrap_or_else(|| "unknown".to_string()),
|
|
},
|
|
});
|
|
state.started_emitted = true;
|
|
}
|
|
out.push(CanonicalStreamFrame {
|
|
id: id.clone(),
|
|
model: model.clone(),
|
|
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
|
index,
|
|
arguments: arguments.to_string(),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if delta.contains_key("tool_calls") {
|
|
recognized_delta = true;
|
|
}
|
|
|
|
if let Some(finish_reason) = normalize_openai_finish_reason(
|
|
choice_object.get("finish_reason").and_then(Value::as_str),
|
|
) {
|
|
recognized_delta = true;
|
|
if let Some(usage) = Self::finish_usage(chunk_object.get("usage")) {
|
|
self.ensure_started(report_context, &mut out);
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason: Some(finish_reason),
|
|
usage: Some(usage),
|
|
},
|
|
});
|
|
self.finished = true;
|
|
} else {
|
|
self.pending_finish_reason = Some(finish_reason);
|
|
}
|
|
}
|
|
if !recognized_delta && !finish_reason_key_present {
|
|
out.push(self.unknown_frame(report_context, Value::Object(choice_object.clone())));
|
|
}
|
|
}
|
|
|
|
Ok(out)
|
|
}
|
|
|
|
pub fn finish(
|
|
&mut self,
|
|
report_context: &Value,
|
|
) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError> {
|
|
if !self.started || self.finished {
|
|
return Ok(Vec::new());
|
|
}
|
|
self.finished = true;
|
|
let (id, model) = self.identity(report_context);
|
|
Ok(vec![CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason: self.pending_finish_reason.take(),
|
|
usage: None,
|
|
},
|
|
}])
|
|
}
|
|
}
|
|
|
|
impl OpenAIResponsesProviderState {
|
|
fn identity(&self, report_context: &Value) -> (String, String) {
|
|
resolve_identity(
|
|
self.response_id.as_deref(),
|
|
self.model.as_deref(),
|
|
report_context,
|
|
"resp-local-stream",
|
|
)
|
|
}
|
|
|
|
fn ensure_started(&mut self, report_context: &Value, out: &mut Vec<CanonicalStreamFrame>) {
|
|
if self.started {
|
|
return;
|
|
}
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::Start,
|
|
});
|
|
self.started = true;
|
|
}
|
|
|
|
fn unknown_frame(&self, report_context: &Value, payload: Value) -> CanonicalStreamFrame {
|
|
let (id, model) = self.identity(report_context);
|
|
CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::UnknownEvent(payload),
|
|
}
|
|
}
|
|
|
|
fn tool_index_for_key(&mut self, key: Option<String>, output_index: Option<usize>) -> usize {
|
|
if let Some(output_index) = output_index {
|
|
if let Some(key) = key.as_ref() {
|
|
self.tool_index_by_key
|
|
.entry(key.clone())
|
|
.or_insert(output_index);
|
|
}
|
|
self.last_tool_index = Some(output_index);
|
|
return output_index;
|
|
}
|
|
if let Some(key) = key.as_ref() {
|
|
if let Some(index) = self.tool_index_by_key.get(key).copied() {
|
|
self.last_tool_index = Some(index);
|
|
return index;
|
|
}
|
|
}
|
|
let index = self.last_tool_index.unwrap_or(self.tool_calls.len());
|
|
if let Some(key) = key {
|
|
self.tool_index_by_key.insert(key, index);
|
|
}
|
|
self.last_tool_index = Some(index);
|
|
index
|
|
}
|
|
|
|
fn emit_missing_text(
|
|
&mut self,
|
|
report_context: &Value,
|
|
out: &mut Vec<CanonicalStreamFrame>,
|
|
text: &str,
|
|
) {
|
|
let missing = if text.starts_with(&self.text) {
|
|
text[self.text.len()..].to_string()
|
|
} else if self.text == text {
|
|
String::new()
|
|
} else {
|
|
text.to_string()
|
|
};
|
|
if missing.is_empty() {
|
|
return;
|
|
}
|
|
self.ensure_started(report_context, out);
|
|
self.text.push_str(&missing);
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::TextDelta(missing),
|
|
});
|
|
}
|
|
|
|
fn emit_missing_reasoning(
|
|
&mut self,
|
|
report_context: &Value,
|
|
out: &mut Vec<CanonicalStreamFrame>,
|
|
reasoning: &str,
|
|
) {
|
|
let missing = if reasoning.starts_with(&self.reasoning) {
|
|
reasoning[self.reasoning.len()..].to_string()
|
|
} else if self.reasoning == reasoning {
|
|
String::new()
|
|
} else {
|
|
reasoning.to_string()
|
|
};
|
|
if missing.is_empty() {
|
|
return;
|
|
}
|
|
self.ensure_started(report_context, out);
|
|
self.reasoning.push_str(&missing);
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::ReasoningDelta(missing),
|
|
});
|
|
}
|
|
|
|
fn emit_missing_reasoning_part_text(
|
|
&mut self,
|
|
report_context: &Value,
|
|
out: &mut Vec<CanonicalStreamFrame>,
|
|
summary_index: usize,
|
|
text: &str,
|
|
) {
|
|
if text.is_empty() {
|
|
return;
|
|
}
|
|
let missing = {
|
|
let current = self.reasoning_parts.entry(summary_index).or_default();
|
|
let missing = if text.starts_with(current.as_str()) {
|
|
text[current.len()..].to_string()
|
|
} else if current.as_str() == text {
|
|
String::new()
|
|
} else if current.is_empty() {
|
|
text.to_string()
|
|
} else {
|
|
String::new()
|
|
};
|
|
if !missing.is_empty() {
|
|
current.push_str(&missing);
|
|
}
|
|
missing
|
|
};
|
|
if missing.is_empty() {
|
|
return;
|
|
}
|
|
self.ensure_started(report_context, out);
|
|
self.reasoning.push_str(&missing);
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::ReasoningDelta(missing),
|
|
});
|
|
}
|
|
|
|
fn emit_tool_call_item(
|
|
&mut self,
|
|
report_context: &Value,
|
|
out: &mut Vec<CanonicalStreamFrame>,
|
|
item: &Map<String, Value>,
|
|
output_index: Option<usize>,
|
|
) {
|
|
if item.get("type").and_then(Value::as_str) != Some("function_call") {
|
|
return;
|
|
}
|
|
self.ensure_started(report_context, out);
|
|
let key = item
|
|
.get("call_id")
|
|
.or_else(|| item.get("id"))
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned);
|
|
let index = self.tool_index_for_key(key, output_index);
|
|
let (id, model) = self.identity(report_context);
|
|
let state = self.tool_calls.entry(index).or_default();
|
|
state.call_id = item
|
|
.get("call_id")
|
|
.or_else(|| item.get("id"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(state.call_id.as_str())
|
|
.to_string();
|
|
state.name = item
|
|
.get("name")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(state.name.as_str())
|
|
.to_string();
|
|
if !state.started_emitted {
|
|
out.push(CanonicalStreamFrame {
|
|
id: id.clone(),
|
|
model: model.clone(),
|
|
event: CanonicalStreamEvent::ToolCallStart {
|
|
index,
|
|
call_id: if state.call_id.is_empty() {
|
|
build_generated_tool_call_id(index)
|
|
} else {
|
|
state.call_id.clone()
|
|
},
|
|
name: if state.name.is_empty() {
|
|
"unknown".to_string()
|
|
} else {
|
|
state.name.clone()
|
|
},
|
|
},
|
|
});
|
|
state.started_emitted = true;
|
|
}
|
|
let completed_arguments = item
|
|
.get("arguments")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
let missing = if completed_arguments.starts_with(&state.arguments) {
|
|
completed_arguments[state.arguments.len()..].to_string()
|
|
} else if state.arguments == completed_arguments {
|
|
String::new()
|
|
} else {
|
|
completed_arguments.clone()
|
|
};
|
|
if missing.is_empty() {
|
|
return;
|
|
}
|
|
state.arguments.push_str(&missing);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
|
index,
|
|
arguments: missing,
|
|
},
|
|
});
|
|
}
|
|
|
|
fn emit_missing_tool_result(
|
|
&mut self,
|
|
report_context: &Value,
|
|
out: &mut Vec<CanonicalStreamFrame>,
|
|
index: usize,
|
|
tool_use_id: String,
|
|
name: Option<String>,
|
|
content: &str,
|
|
) {
|
|
self.ensure_started(report_context, out);
|
|
let state = self.tool_results.entry(index).or_default();
|
|
let missing = if !state.emitted {
|
|
content.to_string()
|
|
} else if content.starts_with(&state.content) {
|
|
content[state.content.len()..].to_string()
|
|
} else if state.content == content {
|
|
String::new()
|
|
} else {
|
|
content.to_string()
|
|
};
|
|
if missing.is_empty() && state.emitted {
|
|
return;
|
|
}
|
|
state.emitted = true;
|
|
state.content.push_str(&missing);
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::ToolResultDelta {
|
|
index,
|
|
tool_use_id,
|
|
name,
|
|
content: missing,
|
|
},
|
|
});
|
|
}
|
|
|
|
fn emit_tool_result_item(
|
|
&mut self,
|
|
report_context: &Value,
|
|
out: &mut Vec<CanonicalStreamFrame>,
|
|
item: &Map<String, Value>,
|
|
output_index: Option<usize>,
|
|
) {
|
|
if item.get("type").and_then(Value::as_str) != Some("function_call_output") {
|
|
return;
|
|
}
|
|
let tool_use_id = item
|
|
.get("call_id")
|
|
.or_else(|| item.get("tool_call_id"))
|
|
.or_else(|| item.get("id"))
|
|
.and_then(Value::as_str)
|
|
.filter(|value| !value.trim().is_empty())
|
|
.unwrap_or("call_auto_0")
|
|
.to_string();
|
|
let index = self.tool_index_for_key(
|
|
Some(format!("function_call_output:{tool_use_id}")),
|
|
output_index,
|
|
);
|
|
let content = openai_tool_result_content_from_value(
|
|
item.get("output")
|
|
.or_else(|| item.get("content"))
|
|
.or_else(|| item.get("delta")),
|
|
);
|
|
let name = item
|
|
.get("name")
|
|
.and_then(Value::as_str)
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(ToOwned::to_owned);
|
|
self.emit_missing_tool_result(report_context, out, index, tool_use_id, name, &content);
|
|
}
|
|
|
|
fn emit_message_item(
|
|
&mut self,
|
|
report_context: &Value,
|
|
out: &mut Vec<CanonicalStreamFrame>,
|
|
item: &Map<String, Value>,
|
|
) {
|
|
if item.get("type").and_then(Value::as_str) != Some("message") {
|
|
return;
|
|
}
|
|
let mut completed_text = String::new();
|
|
for raw_content in item
|
|
.get("content")
|
|
.and_then(Value::as_array)
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
let Some(content) = raw_content.as_object() else {
|
|
continue;
|
|
};
|
|
if content.get("type").and_then(Value::as_str) == Some("output_text") {
|
|
if let Some(text) = content.get("text").and_then(Value::as_str) {
|
|
completed_text.push_str(text);
|
|
}
|
|
}
|
|
}
|
|
if !completed_text.is_empty() {
|
|
self.emit_missing_text(report_context, out, &completed_text);
|
|
}
|
|
}
|
|
|
|
fn emit_reasoning_item(
|
|
&mut self,
|
|
report_context: &Value,
|
|
out: &mut Vec<CanonicalStreamFrame>,
|
|
item: &Map<String, Value>,
|
|
) {
|
|
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
|
|
return;
|
|
}
|
|
let mut completed_reasoning = String::new();
|
|
for raw_summary in item
|
|
.get("summary")
|
|
.and_then(Value::as_array)
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
let Some(summary) = raw_summary.as_object() else {
|
|
continue;
|
|
};
|
|
if summary.get("type").and_then(Value::as_str) == Some("summary_text") {
|
|
if let Some(text) = summary.get("text").and_then(Value::as_str) {
|
|
completed_reasoning.push_str(text);
|
|
}
|
|
}
|
|
}
|
|
if !completed_reasoning.is_empty() {
|
|
self.emit_missing_reasoning(report_context, out, &completed_reasoning);
|
|
}
|
|
}
|
|
|
|
fn emit_image_generation_item(
|
|
&mut self,
|
|
report_context: &Value,
|
|
out: &mut Vec<CanonicalStreamFrame>,
|
|
item: &Map<String, Value>,
|
|
output_index: Option<usize>,
|
|
final_item: bool,
|
|
) {
|
|
if item.get("type").and_then(Value::as_str) != Some("image_generation_call") {
|
|
return;
|
|
}
|
|
if !final_item
|
|
&& !item
|
|
.get("status")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|value| value.eq_ignore_ascii_case("completed"))
|
|
{
|
|
return;
|
|
}
|
|
let has_image_payload = item
|
|
.get("result")
|
|
.or_else(|| item.get("url"))
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.is_some_and(|value| !value.is_empty());
|
|
if !has_image_payload {
|
|
return;
|
|
}
|
|
let index = output_index.unwrap_or(self.image_item_keys.len());
|
|
let key = item
|
|
.get("id")
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned)
|
|
.unwrap_or_else(|| format!("image_generation_call:{index}"));
|
|
if !self.image_item_keys.insert(key) {
|
|
return;
|
|
}
|
|
self.ensure_started(report_context, out);
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::ImageGenerationCall {
|
|
index,
|
|
item: Value::Object(item.clone()),
|
|
},
|
|
});
|
|
}
|
|
|
|
pub fn push_line(
|
|
&mut self,
|
|
report_context: &Value,
|
|
line: Vec<u8>,
|
|
) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError> {
|
|
let Some(value) = decode_json_data_line(&line) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let mut out = Vec::new();
|
|
if let Some(response) = value.get("response").and_then(Value::as_object) {
|
|
self.response_id = response
|
|
.get("id")
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| self.response_id.clone());
|
|
self.model = response
|
|
.get("model")
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| self.model.clone());
|
|
}
|
|
|
|
match value
|
|
.get("type")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
{
|
|
"response.created" | "response.in_progress" => {
|
|
self.ensure_started(report_context, &mut out);
|
|
}
|
|
"response.output_text.delta" | "response.outtext.delta" => {
|
|
let piece = match value.get("delta") {
|
|
Some(Value::String(text)) => text.clone(),
|
|
Some(Value::Object(delta)) => delta
|
|
.get("text")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_string(),
|
|
_ => String::new(),
|
|
};
|
|
if !piece.is_empty() {
|
|
self.ensure_started(report_context, &mut out);
|
|
self.text.push_str(&piece);
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::TextDelta(piece),
|
|
});
|
|
}
|
|
}
|
|
"response.content_part.added" | "response.content_part.done" => {
|
|
if let Some(part) = value.get("part").and_then(Value::as_object) {
|
|
if part.get("type").and_then(Value::as_str) == Some("output_text") {
|
|
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
|
if !text.is_empty() {
|
|
self.emit_missing_text(report_context, &mut out, text);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"response.reasoning_summary_part.added" | "response.reasoning_summary_part.done" => {
|
|
if let Some(part) = value.get("part").and_then(Value::as_object) {
|
|
if part.get("type").and_then(Value::as_str) == Some("summary_text") {
|
|
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
|
let summary_index = value
|
|
.get("summary_index")
|
|
.and_then(Value::as_u64)
|
|
.map(|value| value as usize)
|
|
.unwrap_or(0);
|
|
self.emit_missing_reasoning_part_text(
|
|
report_context,
|
|
&mut out,
|
|
summary_index,
|
|
text,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"response.output_text.done" => {
|
|
let text = value
|
|
.get("text")
|
|
.and_then(Value::as_str)
|
|
.or_else(|| {
|
|
value
|
|
.get("part")
|
|
.and_then(Value::as_object)
|
|
.and_then(|part| part.get("text"))
|
|
.and_then(Value::as_str)
|
|
})
|
|
.unwrap_or_default();
|
|
if !text.is_empty() {
|
|
self.emit_missing_text(report_context, &mut out, text);
|
|
}
|
|
}
|
|
"response.reasoning_summary_text.delta" => {
|
|
let piece = value
|
|
.get("delta")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default();
|
|
if !piece.is_empty() {
|
|
let summary_index = value
|
|
.get("summary_index")
|
|
.and_then(Value::as_u64)
|
|
.map(|value| value as usize)
|
|
.unwrap_or(0);
|
|
self.ensure_started(report_context, &mut out);
|
|
self.reasoning.push_str(piece);
|
|
self.reasoning_parts
|
|
.entry(summary_index)
|
|
.or_default()
|
|
.push_str(piece);
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::ReasoningDelta(piece.to_string()),
|
|
});
|
|
}
|
|
}
|
|
"response.reasoning_summary_text.done" => {
|
|
let text = value
|
|
.get("text")
|
|
.and_then(Value::as_str)
|
|
.or_else(|| {
|
|
value
|
|
.get("part")
|
|
.and_then(Value::as_object)
|
|
.and_then(|part| part.get("text"))
|
|
.and_then(Value::as_str)
|
|
})
|
|
.unwrap_or_default();
|
|
if !text.is_empty() {
|
|
let summary_index = value
|
|
.get("summary_index")
|
|
.and_then(Value::as_u64)
|
|
.map(|value| value as usize)
|
|
.unwrap_or(0);
|
|
self.emit_missing_reasoning_part_text(
|
|
report_context,
|
|
&mut out,
|
|
summary_index,
|
|
text,
|
|
);
|
|
}
|
|
self.ensure_started(report_context, &mut out);
|
|
let (id, model) = self.identity(report_context);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::ReasoningSummaryDone,
|
|
});
|
|
}
|
|
"response.output_item.added" => {
|
|
let Some(item) = value.get("item").and_then(Value::as_object) else {
|
|
return Ok(out);
|
|
};
|
|
let output_index = value
|
|
.get("output_index")
|
|
.and_then(Value::as_u64)
|
|
.map(|value| value as usize);
|
|
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
|
|
"function_call" => {
|
|
self.emit_tool_call_item(report_context, &mut out, item, output_index);
|
|
}
|
|
"function_call_output" => {
|
|
self.emit_tool_result_item(report_context, &mut out, item, output_index);
|
|
}
|
|
"message" => {
|
|
self.emit_message_item(report_context, &mut out, item);
|
|
}
|
|
"reasoning" => {
|
|
self.ensure_started(report_context, &mut out);
|
|
}
|
|
"image_generation_call" => {
|
|
self.emit_image_generation_item(
|
|
report_context,
|
|
&mut out,
|
|
item,
|
|
output_index,
|
|
false,
|
|
);
|
|
}
|
|
_ => {
|
|
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
|
|
}
|
|
}
|
|
}
|
|
"response.function_call_arguments.delta" => {
|
|
let delta = value
|
|
.get("delta")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default();
|
|
if delta.is_empty() {
|
|
return Ok(out);
|
|
}
|
|
self.ensure_started(report_context, &mut out);
|
|
let key = value
|
|
.get("item_id")
|
|
.or_else(|| value.get("call_id"))
|
|
.or_else(|| value.get("id"))
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned);
|
|
let output_index = value
|
|
.get("output_index")
|
|
.and_then(Value::as_u64)
|
|
.map(|value| value as usize);
|
|
let index = self.tool_index_for_key(key, output_index);
|
|
let (id, model) = self.identity(report_context);
|
|
let state = self.tool_calls.entry(index).or_default();
|
|
state.call_id = value
|
|
.get("item_id")
|
|
.or_else(|| value.get("call_id"))
|
|
.or_else(|| value.get("id"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or(state.call_id.as_str())
|
|
.to_string();
|
|
if !state.started_emitted {
|
|
out.push(CanonicalStreamFrame {
|
|
id: id.clone(),
|
|
model: model.clone(),
|
|
event: CanonicalStreamEvent::ToolCallStart {
|
|
index,
|
|
call_id: if state.call_id.is_empty() {
|
|
build_generated_tool_call_id(index)
|
|
} else {
|
|
state.call_id.clone()
|
|
},
|
|
name: if state.name.is_empty() {
|
|
"unknown".to_string()
|
|
} else {
|
|
state.name.clone()
|
|
},
|
|
},
|
|
});
|
|
state.started_emitted = true;
|
|
}
|
|
state.arguments.push_str(delta);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
|
index,
|
|
arguments: delta.to_string(),
|
|
},
|
|
});
|
|
}
|
|
"response.function_call_arguments.done" => {
|
|
let arguments = value
|
|
.get("arguments")
|
|
.and_then(Value::as_str)
|
|
.or_else(|| {
|
|
value
|
|
.get("item")
|
|
.and_then(Value::as_object)
|
|
.and_then(|item| item.get("arguments"))
|
|
.and_then(Value::as_str)
|
|
})
|
|
.unwrap_or_default();
|
|
if arguments.is_empty() {
|
|
return Ok(out);
|
|
}
|
|
self.ensure_started(report_context, &mut out);
|
|
let key = value
|
|
.get("item_id")
|
|
.or_else(|| value.get("call_id"))
|
|
.or_else(|| value.get("id"))
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| {
|
|
value
|
|
.get("item")
|
|
.and_then(Value::as_object)
|
|
.and_then(|item| item.get("call_id").or_else(|| item.get("id")))
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned)
|
|
});
|
|
let output_index = value
|
|
.get("output_index")
|
|
.and_then(Value::as_u64)
|
|
.map(|value| value as usize);
|
|
let index = self.tool_index_for_key(key, output_index);
|
|
let (id, model) = self.identity(report_context);
|
|
let state = self.tool_calls.entry(index).or_default();
|
|
state.call_id = value
|
|
.get("item_id")
|
|
.or_else(|| value.get("call_id"))
|
|
.or_else(|| value.get("id"))
|
|
.and_then(Value::as_str)
|
|
.or_else(|| {
|
|
value
|
|
.get("item")
|
|
.and_then(Value::as_object)
|
|
.and_then(|item| item.get("call_id").or_else(|| item.get("id")))
|
|
.and_then(Value::as_str)
|
|
})
|
|
.unwrap_or(state.call_id.as_str())
|
|
.to_string();
|
|
if !state.started_emitted {
|
|
out.push(CanonicalStreamFrame {
|
|
id: id.clone(),
|
|
model: model.clone(),
|
|
event: CanonicalStreamEvent::ToolCallStart {
|
|
index,
|
|
call_id: if state.call_id.is_empty() {
|
|
build_generated_tool_call_id(index)
|
|
} else {
|
|
state.call_id.clone()
|
|
},
|
|
name: if state.name.is_empty() {
|
|
"unknown".to_string()
|
|
} else {
|
|
state.name.clone()
|
|
},
|
|
},
|
|
});
|
|
state.started_emitted = true;
|
|
}
|
|
let missing = if arguments.starts_with(&state.arguments) {
|
|
arguments[state.arguments.len()..].to_string()
|
|
} else if state.arguments == arguments {
|
|
String::new()
|
|
} else {
|
|
arguments.to_string()
|
|
};
|
|
if !missing.is_empty() {
|
|
state.arguments.push_str(&missing);
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
|
index,
|
|
arguments: missing,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
"response.function_call_output.delta" | "response.function_call_output.done" => {
|
|
let tool_use_id = value
|
|
.get("call_id")
|
|
.or_else(|| value.get("tool_call_id"))
|
|
.or_else(|| value.get("item_id"))
|
|
.or_else(|| value.get("id"))
|
|
.and_then(Value::as_str)
|
|
.filter(|value| !value.trim().is_empty())
|
|
.unwrap_or("call_auto_0")
|
|
.to_string();
|
|
let output_index = value
|
|
.get("output_index")
|
|
.and_then(Value::as_u64)
|
|
.map(|value| value as usize);
|
|
let index = self.tool_index_for_key(
|
|
Some(format!("function_call_output:{tool_use_id}")),
|
|
output_index,
|
|
);
|
|
let content = openai_tool_result_content_from_value(
|
|
value
|
|
.get("delta")
|
|
.or_else(|| value.get("output"))
|
|
.or_else(|| value.get("content")),
|
|
);
|
|
let name = value
|
|
.get("name")
|
|
.and_then(Value::as_str)
|
|
.filter(|value| !value.trim().is_empty())
|
|
.map(ToOwned::to_owned);
|
|
self.emit_missing_tool_result(
|
|
report_context,
|
|
&mut out,
|
|
index,
|
|
tool_use_id,
|
|
name,
|
|
&content,
|
|
);
|
|
}
|
|
"response.output_item.done" => {
|
|
let Some(item) = value.get("item").and_then(Value::as_object) else {
|
|
return Ok(out);
|
|
};
|
|
let output_index = value
|
|
.get("output_index")
|
|
.and_then(Value::as_u64)
|
|
.map(|value| value as usize);
|
|
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
|
|
"function_call" => {
|
|
self.emit_tool_call_item(report_context, &mut out, item, output_index);
|
|
}
|
|
"function_call_output" => {
|
|
self.emit_tool_result_item(report_context, &mut out, item, output_index);
|
|
}
|
|
"message" => {
|
|
self.emit_message_item(report_context, &mut out, item);
|
|
}
|
|
"reasoning" => {
|
|
self.emit_reasoning_item(report_context, &mut out, item);
|
|
}
|
|
"image_generation_call" => {
|
|
self.emit_image_generation_item(
|
|
report_context,
|
|
&mut out,
|
|
item,
|
|
output_index,
|
|
true,
|
|
);
|
|
}
|
|
_ => {
|
|
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
|
|
}
|
|
}
|
|
}
|
|
"response.completed" => {
|
|
let Some(response) = value.get("response").and_then(Value::as_object) else {
|
|
return Ok(out);
|
|
};
|
|
self.ensure_started(report_context, &mut out);
|
|
let (id, model) = self.identity(report_context);
|
|
|
|
for (output_index, raw_item) in response
|
|
.get("output")
|
|
.and_then(Value::as_array)
|
|
.into_iter()
|
|
.flatten()
|
|
.enumerate()
|
|
{
|
|
let Some(item) = raw_item.as_object() else {
|
|
continue;
|
|
};
|
|
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
|
|
"message" => {
|
|
self.emit_message_item(report_context, &mut out, item);
|
|
}
|
|
"function_call" => {
|
|
self.emit_tool_call_item(
|
|
report_context,
|
|
&mut out,
|
|
item,
|
|
Some(output_index),
|
|
);
|
|
}
|
|
"function_call_output" => {
|
|
self.emit_tool_result_item(
|
|
report_context,
|
|
&mut out,
|
|
item,
|
|
Some(output_index),
|
|
);
|
|
}
|
|
"reasoning" => {
|
|
self.emit_reasoning_item(report_context, &mut out, item);
|
|
}
|
|
"image_generation_call" => {
|
|
self.emit_image_generation_item(
|
|
report_context,
|
|
&mut out,
|
|
item,
|
|
Some(output_index),
|
|
true,
|
|
);
|
|
}
|
|
_ => {
|
|
out.push(
|
|
self.unknown_frame(report_context, Value::Object(item.clone())),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
let finish_reason = if self.tool_calls.is_empty() {
|
|
Some("stop".to_string())
|
|
} else {
|
|
Some("tool_calls".to_string())
|
|
};
|
|
out.push(CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason,
|
|
usage: canonical_usage_from_openai_usage(response.get("usage")),
|
|
},
|
|
});
|
|
self.finished = true;
|
|
}
|
|
_ => {
|
|
out.push(self.unknown_frame(report_context, value.clone()));
|
|
}
|
|
}
|
|
|
|
Ok(out)
|
|
}
|
|
|
|
pub fn finish(
|
|
&mut self,
|
|
report_context: &Value,
|
|
) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError> {
|
|
if !self.started || self.finished {
|
|
return Ok(Vec::new());
|
|
}
|
|
self.finished = true;
|
|
let (id, model) = self.identity(report_context);
|
|
let finish_reason = if self.tool_calls.is_empty() {
|
|
Some("stop".to_string())
|
|
} else {
|
|
Some("tool_calls".to_string())
|
|
};
|
|
Ok(vec![CanonicalStreamFrame {
|
|
id,
|
|
model,
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason,
|
|
usage: None,
|
|
},
|
|
}])
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct OpenAIChatClientEmitter {
|
|
response_id: Option<String>,
|
|
model: Option<String>,
|
|
started: bool,
|
|
finished: bool,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct OpenAIResponsesClientToolState {
|
|
call_id: String,
|
|
name: String,
|
|
arguments: String,
|
|
output_index: Option<usize>,
|
|
web_search: bool,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct OpenAIResponsesClientToolResultState {
|
|
tool_use_id: String,
|
|
name: Option<String>,
|
|
content: String,
|
|
output_index: Option<usize>,
|
|
item_started: bool,
|
|
}
|
|
|
|
fn is_responses_web_search_tool(name: &str) -> bool {
|
|
matches!(name, "web_search" | "web_search_preview")
|
|
}
|
|
|
|
fn web_search_query_from_arguments(arguments: &str) -> String {
|
|
serde_json::from_str::<Value>(arguments)
|
|
.ok()
|
|
.and_then(|value| {
|
|
value
|
|
.get("query")
|
|
.and_then(Value::as_str)
|
|
.map(ToOwned::to_owned)
|
|
.or_else(|| value.as_str().map(ToOwned::to_owned))
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct OpenAIResponsesClientEmitter {
|
|
response_id: Option<String>,
|
|
model: Option<String>,
|
|
message_item_id: Option<String>,
|
|
reasoning_item_id: Option<String>,
|
|
started: bool,
|
|
finished: bool,
|
|
sequence_number: u64,
|
|
next_output_index: usize,
|
|
reasoning_item_started: bool,
|
|
reasoning_part_started: bool,
|
|
reasoning_output_index: Option<usize>,
|
|
text_item_started: bool,
|
|
text_part_started: bool,
|
|
message_output_index: Option<usize>,
|
|
text: String,
|
|
reasoning: String,
|
|
reasoning_part: String,
|
|
reasoning_summary_parts: Vec<String>,
|
|
tool_calls: BTreeMap<usize, OpenAIResponsesClientToolState>,
|
|
tool_results: BTreeMap<usize, OpenAIResponsesClientToolResultState>,
|
|
image_generation_items: BTreeMap<usize, Value>,
|
|
}
|
|
|
|
impl OpenAIChatClientEmitter {
|
|
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
|
self.response_id = Some(frame.id.clone());
|
|
self.model = Some(frame.model.clone());
|
|
}
|
|
|
|
fn ensure_started(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
if self.started {
|
|
return Ok(Vec::new());
|
|
}
|
|
self.started = true;
|
|
encode_json_sse(
|
|
None,
|
|
&build_openai_chat_role_chunk(
|
|
self.response_id
|
|
.as_deref()
|
|
.unwrap_or("chatcmpl-local-stream"),
|
|
self.model.as_deref().unwrap_or("unknown"),
|
|
),
|
|
)
|
|
}
|
|
|
|
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
self.update_identity(&frame);
|
|
match frame.event {
|
|
CanonicalStreamEvent::Start => self.ensure_started(),
|
|
CanonicalStreamEvent::TextDelta(text) => {
|
|
let mut out = self.ensure_started()?;
|
|
out.extend(encode_json_sse(
|
|
None,
|
|
&build_openai_chat_chunk(
|
|
self.response_id
|
|
.as_deref()
|
|
.unwrap_or("chatcmpl-local-stream"),
|
|
self.model.as_deref().unwrap_or("unknown"),
|
|
text,
|
|
None,
|
|
None,
|
|
),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::ReasoningDelta(text) => {
|
|
let mut out = self.ensure_started()?;
|
|
out.extend(encode_json_sse(
|
|
None,
|
|
&json!({
|
|
"id": self.response_id
|
|
.as_deref()
|
|
.unwrap_or("chatcmpl-local-stream"),
|
|
"object": "chat.completion.chunk",
|
|
"model": self.model.as_deref().unwrap_or("unknown"),
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": {
|
|
"reasoning_content": text,
|
|
},
|
|
"finish_reason": Value::Null
|
|
}]
|
|
}),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::ReasoningSummaryDone => {
|
|
// CPA strategy: emit "\n\n" as paragraph separator between
|
|
// reasoning sections, matching CPA's Chat downstream behavior.
|
|
let mut out = self.ensure_started()?;
|
|
out.extend(encode_json_sse(
|
|
None,
|
|
&json!({
|
|
"id": self.response_id
|
|
.as_deref()
|
|
.unwrap_or("chatcmpl-local-stream"),
|
|
"object": "chat.completion.chunk",
|
|
"model": self.model.as_deref().unwrap_or("unknown"),
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": {
|
|
"reasoning_content": "\n\n",
|
|
},
|
|
"finish_reason": Value::Null
|
|
}]
|
|
}),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::ReasoningSignature(_) => Ok(Vec::new()),
|
|
CanonicalStreamEvent::ContentPart(part) => {
|
|
let placeholder = openai_stream_placeholder_for_content_part(&part);
|
|
let mut out = self.ensure_started()?;
|
|
out.extend(encode_json_sse(
|
|
None,
|
|
&build_openai_chat_chunk(
|
|
self.response_id
|
|
.as_deref()
|
|
.unwrap_or("chatcmpl-local-stream"),
|
|
self.model.as_deref().unwrap_or("unknown"),
|
|
placeholder,
|
|
None,
|
|
None,
|
|
),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::ImageGenerationCall { item, .. } => {
|
|
let Some(part) = content_part_from_openai_image_generation_item(&item) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let placeholder = openai_stream_placeholder_for_content_part(&part);
|
|
let mut out = self.ensure_started()?;
|
|
out.extend(encode_json_sse(
|
|
None,
|
|
&build_openai_chat_chunk(
|
|
self.response_id
|
|
.as_deref()
|
|
.unwrap_or("chatcmpl-local-stream"),
|
|
self.model.as_deref().unwrap_or("unknown"),
|
|
placeholder,
|
|
None,
|
|
None,
|
|
),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::ToolCallStart {
|
|
index,
|
|
call_id,
|
|
name,
|
|
} => {
|
|
let mut out = self.ensure_started()?;
|
|
out.extend(encode_json_sse(
|
|
None,
|
|
&build_openai_chat_chunk(
|
|
self.response_id
|
|
.as_deref()
|
|
.unwrap_or("chatcmpl-local-stream"),
|
|
self.model.as_deref().unwrap_or("unknown"),
|
|
String::new(),
|
|
Some(vec![json!({
|
|
"index": index,
|
|
"id": call_id,
|
|
"type": "function",
|
|
"function": {
|
|
"name": name,
|
|
"arguments": "",
|
|
}
|
|
})]),
|
|
None,
|
|
),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
|
let mut out = self.ensure_started()?;
|
|
out.extend(encode_json_sse(
|
|
None,
|
|
&json!({
|
|
"id": self.response_id
|
|
.as_deref()
|
|
.unwrap_or("chatcmpl-local-stream"),
|
|
"object": "chat.completion.chunk",
|
|
"model": self.model.as_deref().unwrap_or("unknown"),
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": {
|
|
"tool_calls": [{
|
|
"index": index,
|
|
"function": {
|
|
"arguments": arguments,
|
|
}
|
|
}]
|
|
},
|
|
"finish_reason": Value::Null
|
|
}]
|
|
}),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::ToolResultDelta {
|
|
index: _,
|
|
tool_use_id,
|
|
name,
|
|
content,
|
|
} => {
|
|
let mut out = self.ensure_started()?;
|
|
let mut delta = Map::new();
|
|
delta.insert("role".to_string(), Value::String("tool".to_string()));
|
|
delta.insert("tool_call_id".to_string(), Value::String(tool_use_id));
|
|
if let Some(name) = name.filter(|value| !value.trim().is_empty()) {
|
|
delta.insert("name".to_string(), Value::String(name));
|
|
}
|
|
delta.insert("content".to_string(), Value::String(content));
|
|
out.extend(encode_json_sse(
|
|
None,
|
|
&json!({
|
|
"id": self.response_id
|
|
.as_deref()
|
|
.unwrap_or("chatcmpl-local-stream"),
|
|
"object": "chat.completion.chunk",
|
|
"model": self.model.as_deref().unwrap_or("unknown"),
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": Value::Object(delta),
|
|
"finish_reason": Value::Null
|
|
}]
|
|
}),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
|
|
CanonicalStreamEvent::Finish {
|
|
finish_reason,
|
|
usage,
|
|
} => {
|
|
if self.finished {
|
|
return Ok(Vec::new());
|
|
}
|
|
let mut out = self.ensure_started()?;
|
|
out.extend(encode_json_sse(
|
|
None,
|
|
&build_openai_chat_finish_chunk(
|
|
self.response_id
|
|
.as_deref()
|
|
.unwrap_or("chatcmpl-local-stream"),
|
|
self.model.as_deref().unwrap_or("unknown"),
|
|
finish_reason.as_deref(),
|
|
),
|
|
)?);
|
|
if let Some(usage) = usage {
|
|
out.extend(encode_json_sse(
|
|
None,
|
|
&build_openai_chat_usage_chunk_from_usage(
|
|
self.response_id
|
|
.as_deref()
|
|
.unwrap_or("chatcmpl-local-stream"),
|
|
self.model.as_deref().unwrap_or("unknown"),
|
|
&usage,
|
|
),
|
|
)?);
|
|
}
|
|
out.extend(encode_done_sse());
|
|
self.finished = true;
|
|
Ok(out)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn finish(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
if !self.started || self.finished {
|
|
return Ok(Vec::new());
|
|
}
|
|
let out = encode_json_sse(
|
|
None,
|
|
&build_openai_chat_finish_chunk(
|
|
self.response_id
|
|
.as_deref()
|
|
.unwrap_or("chatcmpl-local-stream"),
|
|
self.model.as_deref().unwrap_or("unknown"),
|
|
None,
|
|
),
|
|
)?;
|
|
self.finished = true;
|
|
let mut bytes = out;
|
|
bytes.extend(encode_done_sse());
|
|
Ok(bytes)
|
|
}
|
|
}
|
|
|
|
impl OpenAIResponsesClientEmitter {
|
|
fn response_id(&self) -> &str {
|
|
self.response_id.as_deref().unwrap_or("resp-local-stream")
|
|
}
|
|
|
|
fn model(&self) -> &str {
|
|
self.model.as_deref().unwrap_or("unknown")
|
|
}
|
|
|
|
fn message_item_id(&self) -> String {
|
|
self.message_item_id
|
|
.clone()
|
|
.unwrap_or_else(|| format!("{}_msg", self.response_id()))
|
|
}
|
|
|
|
fn reasoning_item_id(&self) -> String {
|
|
self.reasoning_item_id
|
|
.clone()
|
|
.unwrap_or_else(|| format!("{}_rs_0", self.response_id()))
|
|
}
|
|
|
|
fn ensure_message_item_id(&mut self) -> String {
|
|
if self.message_item_id.is_none() {
|
|
self.message_item_id = Some(format!("{}_msg", self.response_id()));
|
|
}
|
|
self.message_item_id()
|
|
}
|
|
|
|
fn ensure_reasoning_item_id(&mut self) -> String {
|
|
if self.reasoning_item_id.is_none() {
|
|
self.reasoning_item_id = Some(format!("{}_rs_0", self.response_id()));
|
|
}
|
|
self.reasoning_item_id()
|
|
}
|
|
|
|
fn in_progress_response(&self) -> Value {
|
|
json!({
|
|
"id": self.response_id(),
|
|
"object": "response",
|
|
"model": self.model(),
|
|
"status": "in_progress",
|
|
"output": [],
|
|
})
|
|
}
|
|
|
|
fn allocate_output_index(&mut self) -> usize {
|
|
let output_index = self.next_output_index;
|
|
self.next_output_index += 1;
|
|
output_index
|
|
}
|
|
|
|
fn next_sequence_number(&mut self) -> u64 {
|
|
self.sequence_number += 1;
|
|
self.sequence_number
|
|
}
|
|
|
|
fn encode_response_event(
|
|
&mut self,
|
|
event: &str,
|
|
mut payload: Value,
|
|
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
if let Some(object) = payload.as_object_mut() {
|
|
object.insert(
|
|
"sequence_number".to_string(),
|
|
Value::from(self.next_sequence_number()),
|
|
);
|
|
}
|
|
encode_json_sse(Some(event), &payload)
|
|
}
|
|
|
|
fn update_identity(&mut self, frame: &CanonicalStreamFrame) {
|
|
self.response_id = Some(frame.id.clone().replace("chatcmpl", "resp"));
|
|
self.model = Some(frame.model.clone());
|
|
}
|
|
|
|
fn ensure_started(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
if self.started {
|
|
return Ok(Vec::new());
|
|
}
|
|
self.started = true;
|
|
let mut out = self.encode_response_event(
|
|
"response.created",
|
|
json!({
|
|
"type": "response.created",
|
|
"response": self.in_progress_response(),
|
|
}),
|
|
)?;
|
|
out.extend(self.encode_response_event(
|
|
"response.in_progress",
|
|
json!({
|
|
"type": "response.in_progress",
|
|
"response": self.in_progress_response(),
|
|
}),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
|
|
fn ensure_reasoning_output_index(&mut self) -> usize {
|
|
if let Some(output_index) = self.reasoning_output_index {
|
|
return output_index;
|
|
}
|
|
let output_index = self.allocate_output_index();
|
|
self.reasoning_output_index = Some(output_index);
|
|
output_index
|
|
}
|
|
|
|
fn current_reasoning_summary_index(&self) -> usize {
|
|
self.reasoning_summary_parts.len()
|
|
}
|
|
|
|
fn ensure_message_output_index(&mut self) -> usize {
|
|
if let Some(output_index) = self.message_output_index {
|
|
return output_index;
|
|
}
|
|
let output_index = self.allocate_output_index();
|
|
self.message_output_index = Some(output_index);
|
|
output_index
|
|
}
|
|
|
|
fn ensure_tool_output_index(&mut self, index: usize) -> usize {
|
|
if let Some(output_index) = self
|
|
.tool_calls
|
|
.get(&index)
|
|
.and_then(|state| state.output_index)
|
|
{
|
|
return output_index;
|
|
}
|
|
let output_index = self.allocate_output_index();
|
|
self.tool_calls.entry(index).or_default().output_index = Some(output_index);
|
|
output_index
|
|
}
|
|
|
|
fn ensure_tool_result_output_index(&mut self, index: usize) -> usize {
|
|
if let Some(output_index) = self
|
|
.tool_results
|
|
.get(&index)
|
|
.and_then(|state| state.output_index)
|
|
{
|
|
return output_index;
|
|
}
|
|
let output_index = self.allocate_output_index();
|
|
self.tool_results.entry(index).or_default().output_index = Some(output_index);
|
|
output_index
|
|
}
|
|
|
|
fn ensure_image_generation_output_index(&mut self, index: usize) -> usize {
|
|
self.next_output_index = self.next_output_index.max(index.saturating_add(1));
|
|
index
|
|
}
|
|
|
|
fn ensure_reasoning_item_started(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
let mut out = self.ensure_started()?;
|
|
let output_index = self.ensure_reasoning_output_index();
|
|
let item_id = self.ensure_reasoning_item_id();
|
|
if !self.reasoning_item_started {
|
|
out.extend(self.encode_response_event(
|
|
"response.output_item.added",
|
|
json!({
|
|
"type": "response.output_item.added",
|
|
"response_id": self.response_id(),
|
|
"output_index": output_index,
|
|
"item": {
|
|
"type": "reasoning",
|
|
"id": item_id.clone(),
|
|
"summary": [],
|
|
}
|
|
}),
|
|
)?);
|
|
self.reasoning_item_started = true;
|
|
}
|
|
if !self.reasoning_part_started {
|
|
let summary_index = self.current_reasoning_summary_index();
|
|
out.extend(self.encode_response_event(
|
|
"response.reasoning_summary_part.added",
|
|
json!({
|
|
"type": "response.reasoning_summary_part.added",
|
|
"response_id": self.response_id(),
|
|
"item_id": item_id,
|
|
"output_index": output_index,
|
|
"summary_index": summary_index,
|
|
"part": {
|
|
"type": "summary_text",
|
|
"text": "",
|
|
}
|
|
}),
|
|
)?);
|
|
self.reasoning_part_started = true;
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn ensure_text_item_started(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
let mut out = self.ensure_started()?;
|
|
let output_index = self.ensure_message_output_index();
|
|
let item_id = self.ensure_message_item_id();
|
|
if !self.text_item_started {
|
|
out.extend(self.encode_response_event(
|
|
"response.output_item.added",
|
|
json!({
|
|
"type": "response.output_item.added",
|
|
"response_id": self.response_id(),
|
|
"output_index": output_index,
|
|
"item": {
|
|
"type": "message",
|
|
"id": item_id.clone(),
|
|
"status": "in_progress",
|
|
"role": "assistant",
|
|
"content": [],
|
|
}
|
|
}),
|
|
)?);
|
|
self.text_item_started = true;
|
|
}
|
|
if !self.text_part_started {
|
|
out.extend(self.encode_response_event(
|
|
"response.content_part.added",
|
|
json!({
|
|
"type": "response.content_part.added",
|
|
"response_id": self.response_id(),
|
|
"output_index": output_index,
|
|
"item_id": item_id,
|
|
"content_index": 0,
|
|
"part": {
|
|
"type": "output_text",
|
|
"text": "",
|
|
"annotations": [],
|
|
}
|
|
}),
|
|
)?);
|
|
self.text_part_started = true;
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn finish_text_item(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
if !self.text_item_started {
|
|
return Ok(Vec::new());
|
|
}
|
|
let item_id = self.message_item_id();
|
|
let output_index = self.message_output_index.unwrap_or(0);
|
|
let mut out = Vec::new();
|
|
if self.text_part_started {
|
|
out.extend(self.encode_response_event(
|
|
"response.output_text.done",
|
|
json!({
|
|
"type": "response.output_text.done",
|
|
"response_id": self.response_id(),
|
|
"output_index": output_index,
|
|
"item_id": item_id.clone(),
|
|
"content_index": 0,
|
|
"text": self.text.as_str(),
|
|
}),
|
|
)?);
|
|
out.extend(self.encode_response_event(
|
|
"response.content_part.done",
|
|
json!({
|
|
"type": "response.content_part.done",
|
|
"response_id": self.response_id(),
|
|
"output_index": output_index,
|
|
"item_id": item_id.clone(),
|
|
"content_index": 0,
|
|
"part": {
|
|
"type": "output_text",
|
|
"text": self.text.as_str(),
|
|
"annotations": [],
|
|
}
|
|
}),
|
|
)?);
|
|
}
|
|
out.extend(self.encode_response_event(
|
|
"response.output_item.done",
|
|
json!({
|
|
"type": "response.output_item.done",
|
|
"response_id": self.response_id(),
|
|
"output_index": output_index,
|
|
"item": {
|
|
"type": "message",
|
|
"id": item_id,
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{
|
|
"type": "output_text",
|
|
"text": self.text.as_str(),
|
|
"annotations": [],
|
|
}],
|
|
}
|
|
}),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
|
|
fn finish_reasoning_item(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
if !self.reasoning_item_started {
|
|
return Ok(Vec::new());
|
|
}
|
|
let output_index = self.reasoning_output_index.unwrap_or(0);
|
|
let item_id = self.reasoning_item_id();
|
|
let mut out = Vec::new();
|
|
if self.reasoning_part_started {
|
|
let summary_index = self.current_reasoning_summary_index();
|
|
let part_text = self.reasoning_part.clone();
|
|
out.extend(self.encode_response_event(
|
|
"response.reasoning_summary_text.done",
|
|
json!({
|
|
"type": "response.reasoning_summary_text.done",
|
|
"response_id": self.response_id(),
|
|
"item_id": item_id.clone(),
|
|
"output_index": output_index,
|
|
"summary_index": summary_index,
|
|
"text": part_text.as_str(),
|
|
}),
|
|
)?);
|
|
out.extend(self.encode_response_event(
|
|
"response.reasoning_summary_part.done",
|
|
json!({
|
|
"type": "response.reasoning_summary_part.done",
|
|
"response_id": self.response_id(),
|
|
"item_id": item_id.clone(),
|
|
"output_index": output_index,
|
|
"summary_index": summary_index,
|
|
"part": {
|
|
"type": "summary_text",
|
|
"text": part_text.as_str(),
|
|
}
|
|
}),
|
|
)?);
|
|
self.reasoning_summary_parts.push(part_text);
|
|
self.reasoning_part.clear();
|
|
self.reasoning_part_started = false;
|
|
}
|
|
let summary = if self.reasoning_summary_parts.is_empty() {
|
|
if self.reasoning.trim().is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
vec![json!({
|
|
"type": "summary_text",
|
|
"text": self.reasoning.as_str(),
|
|
})]
|
|
}
|
|
} else {
|
|
self.reasoning_summary_parts
|
|
.iter()
|
|
.map(|text| {
|
|
json!({
|
|
"type": "summary_text",
|
|
"text": text,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>()
|
|
};
|
|
out.extend(self.encode_response_event(
|
|
"response.output_item.done",
|
|
json!({
|
|
"type": "response.output_item.done",
|
|
"response_id": self.response_id(),
|
|
"output_index": output_index,
|
|
"item": {
|
|
"type": "reasoning",
|
|
"id": item_id,
|
|
"summary": summary,
|
|
}
|
|
}),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
|
|
fn finish_tool_items(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
let mut out = Vec::new();
|
|
let indices = self.tool_calls.keys().copied().collect::<Vec<_>>();
|
|
for index in indices {
|
|
let output_index = self.ensure_tool_output_index(index);
|
|
let state = self.tool_calls.get(&index).cloned().unwrap_or_default();
|
|
let item_id = if state.call_id.is_empty() {
|
|
build_generated_tool_call_id(index)
|
|
} else {
|
|
state.call_id.clone()
|
|
};
|
|
let name = if state.name.is_empty() {
|
|
"unknown".to_string()
|
|
} else {
|
|
state.name.clone()
|
|
};
|
|
if state.web_search {
|
|
out.extend(self.encode_response_event(
|
|
"response.output_item.done",
|
|
json!({
|
|
"type": "response.output_item.done",
|
|
"response_id": self.response_id(),
|
|
"output_index": output_index,
|
|
"item": {
|
|
"type": "web_search_call",
|
|
"id": item_id,
|
|
"status": "completed",
|
|
"action": {
|
|
"type": "search",
|
|
"query": web_search_query_from_arguments(&state.arguments),
|
|
},
|
|
}
|
|
}),
|
|
)?);
|
|
continue;
|
|
}
|
|
out.extend(self.encode_response_event(
|
|
"response.function_call_arguments.done",
|
|
json!({
|
|
"type": "response.function_call_arguments.done",
|
|
"response_id": self.response_id(),
|
|
"output_index": output_index,
|
|
"item_id": item_id.clone(),
|
|
"call_id": item_id.clone(),
|
|
"arguments": state.arguments.as_str(),
|
|
}),
|
|
)?);
|
|
out.extend(self.encode_response_event(
|
|
"response.output_item.done",
|
|
json!({
|
|
"type": "response.output_item.done",
|
|
"response_id": self.response_id(),
|
|
"output_index": output_index,
|
|
"item": {
|
|
"type": "function_call",
|
|
"id": item_id.clone(),
|
|
"call_id": item_id,
|
|
"name": name,
|
|
"arguments": state.arguments.as_str(),
|
|
"status": "completed",
|
|
}
|
|
}),
|
|
)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn finish_tool_result_items(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
let mut out = Vec::new();
|
|
let indices = self.tool_results.keys().copied().collect::<Vec<_>>();
|
|
for index in indices {
|
|
let output_index = self.ensure_tool_result_output_index(index);
|
|
let state = self.tool_results.get(&index).cloned().unwrap_or_default();
|
|
let item_id = if state.tool_use_id.is_empty() {
|
|
build_generated_tool_call_id(index)
|
|
} else {
|
|
state.tool_use_id.clone()
|
|
};
|
|
let mut item = Map::new();
|
|
item.insert(
|
|
"type".to_string(),
|
|
Value::String("function_call_output".to_string()),
|
|
);
|
|
item.insert("id".to_string(), Value::String(format!("{item_id}_output")));
|
|
item.insert("call_id".to_string(), Value::String(item_id));
|
|
if let Some(name) = state
|
|
.name
|
|
.as_ref()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.cloned()
|
|
{
|
|
item.insert("name".to_string(), Value::String(name));
|
|
}
|
|
item.insert("output".to_string(), Value::String(state.content.clone()));
|
|
out.extend(self.encode_response_event(
|
|
"response.output_item.done",
|
|
json!({
|
|
"type": "response.output_item.done",
|
|
"response_id": self.response_id(),
|
|
"output_index": output_index,
|
|
"item": Value::Object(item),
|
|
}),
|
|
)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn emit_image_generation_call_item(
|
|
&mut self,
|
|
index: usize,
|
|
item: Value,
|
|
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
let mut out = self.ensure_started()?;
|
|
let output_index = self.ensure_image_generation_output_index(index);
|
|
let mut item = item.as_object().cloned().unwrap_or_default();
|
|
item.insert(
|
|
"type".to_string(),
|
|
Value::String("image_generation_call".to_string()),
|
|
);
|
|
if !item.contains_key("id") {
|
|
item.insert(
|
|
"id".to_string(),
|
|
Value::String(format!("{}_ig_{}", self.response_id(), output_index)),
|
|
);
|
|
}
|
|
if !item.contains_key("status") {
|
|
item.insert("status".to_string(), Value::String("completed".to_string()));
|
|
}
|
|
let item = Value::Object(item);
|
|
self.image_generation_items
|
|
.insert(output_index, item.clone());
|
|
out.extend(self.encode_response_event(
|
|
"response.output_item.done",
|
|
json!({
|
|
"type": "response.output_item.done",
|
|
"response_id": self.response_id(),
|
|
"output_index": output_index,
|
|
"item": item,
|
|
}),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
|
|
fn completed_response(&self, usage: CanonicalUsage) -> Value {
|
|
let mut ordered_output = Vec::new();
|
|
let summary = if self.reasoning_summary_parts.is_empty() {
|
|
if self.reasoning.trim().is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
vec![json!({
|
|
"type": "summary_text",
|
|
"text": self.reasoning.as_str(),
|
|
})]
|
|
}
|
|
} else {
|
|
self.reasoning_summary_parts
|
|
.iter()
|
|
.map(|text| {
|
|
json!({
|
|
"type": "summary_text",
|
|
"text": text,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>()
|
|
};
|
|
if !summary.is_empty() {
|
|
ordered_output.push((
|
|
self.reasoning_output_index.unwrap_or(0),
|
|
json!({
|
|
"type": "reasoning",
|
|
"id": self.reasoning_item_id(),
|
|
"status": "completed",
|
|
"summary": summary,
|
|
}),
|
|
));
|
|
}
|
|
if self.text_item_started || !self.text.is_empty() {
|
|
ordered_output.push((
|
|
self.message_output_index.unwrap_or(0),
|
|
json!({
|
|
"type": "message",
|
|
"id": self.message_item_id(),
|
|
"role": "assistant",
|
|
"status": "completed",
|
|
"content": [{
|
|
"type": "output_text",
|
|
"text": self.text.as_str(),
|
|
"annotations": [],
|
|
}],
|
|
}),
|
|
));
|
|
}
|
|
for (index, state) in &self.tool_calls {
|
|
if let Some(output_index) = state.output_index {
|
|
let item_id = if state.call_id.is_empty() {
|
|
build_generated_tool_call_id(*index)
|
|
} else {
|
|
state.call_id.clone()
|
|
};
|
|
if state.web_search {
|
|
ordered_output.push((
|
|
output_index,
|
|
json!({
|
|
"type": "web_search_call",
|
|
"id": item_id,
|
|
"status": "completed",
|
|
"action": {
|
|
"type": "search",
|
|
"query": web_search_query_from_arguments(&state.arguments),
|
|
},
|
|
}),
|
|
));
|
|
continue;
|
|
}
|
|
ordered_output.push((
|
|
output_index,
|
|
json!({
|
|
"type": "function_call",
|
|
"id": item_id.clone(),
|
|
"call_id": item_id,
|
|
"name": if state.name.is_empty() {
|
|
"unknown".to_string()
|
|
} else {
|
|
state.name.clone()
|
|
},
|
|
"arguments": state.arguments.clone(),
|
|
"status": "completed",
|
|
}),
|
|
));
|
|
}
|
|
}
|
|
for (index, state) in &self.tool_results {
|
|
if let Some(output_index) = state.output_index {
|
|
let item_id = if state.tool_use_id.is_empty() {
|
|
build_generated_tool_call_id(*index)
|
|
} else {
|
|
state.tool_use_id.clone()
|
|
};
|
|
let mut item = Map::new();
|
|
item.insert(
|
|
"type".to_string(),
|
|
Value::String("function_call_output".to_string()),
|
|
);
|
|
item.insert("id".to_string(), Value::String(format!("{item_id}_output")));
|
|
item.insert("call_id".to_string(), Value::String(item_id));
|
|
if let Some(name) = state
|
|
.name
|
|
.as_ref()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.cloned()
|
|
{
|
|
item.insert("name".to_string(), Value::String(name));
|
|
}
|
|
item.insert("output".to_string(), Value::String(state.content.clone()));
|
|
ordered_output.push((output_index, Value::Object(item)));
|
|
}
|
|
}
|
|
for (output_index, item) in &self.image_generation_items {
|
|
ordered_output.push((*output_index, item.clone()));
|
|
}
|
|
ordered_output.sort_by_key(|(output_index, _)| *output_index);
|
|
|
|
json!({
|
|
"id": self.response_id(),
|
|
"object": "response",
|
|
"status": "completed",
|
|
"model": self.model(),
|
|
"output": ordered_output
|
|
.into_iter()
|
|
.map(|(_, item)| item)
|
|
.collect::<Vec<_>>(),
|
|
"usage": openai_responses_usage_from_usage(&usage),
|
|
})
|
|
}
|
|
|
|
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
self.update_identity(&frame);
|
|
match frame.event {
|
|
CanonicalStreamEvent::Start => self.ensure_started(),
|
|
CanonicalStreamEvent::TextDelta(text) => {
|
|
let mut out = self.ensure_text_item_started()?;
|
|
self.text.push_str(&text);
|
|
out.extend(self.encode_response_event(
|
|
"response.output_text.delta",
|
|
json!({
|
|
"type": "response.output_text.delta",
|
|
"response_id": self.response_id(),
|
|
"output_index": self.message_output_index.unwrap_or(0),
|
|
"item_id": self.message_item_id(),
|
|
"content_index": 0,
|
|
"delta": text,
|
|
}),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::ReasoningDelta(text) => {
|
|
let mut out = self.ensure_reasoning_item_started()?;
|
|
self.reasoning.push_str(&text);
|
|
self.reasoning_part.push_str(&text);
|
|
out.extend(self.encode_response_event(
|
|
"response.reasoning_summary_text.delta",
|
|
json!({
|
|
"type": "response.reasoning_summary_text.delta",
|
|
"response_id": self.response_id(),
|
|
"item_id": self.reasoning_item_id(),
|
|
"output_index": self.reasoning_output_index.unwrap_or(0),
|
|
"summary_index": self.current_reasoning_summary_index(),
|
|
"delta": text,
|
|
}),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::ReasoningSummaryDone => {
|
|
// Close the current reasoning part and reset state so the next
|
|
// ReasoningDelta starts a fresh part within the same item.
|
|
if !self.reasoning_item_started || !self.reasoning_part_started {
|
|
return Ok(Vec::new());
|
|
}
|
|
let output_index = self.reasoning_output_index.unwrap_or(0);
|
|
let item_id = self.reasoning_item_id();
|
|
let summary_index = self.current_reasoning_summary_index();
|
|
let part_text = self.reasoning_part.clone();
|
|
let mut out = Vec::new();
|
|
out.extend(self.encode_response_event(
|
|
"response.reasoning_summary_text.done",
|
|
json!({
|
|
"type": "response.reasoning_summary_text.done",
|
|
"response_id": self.response_id(),
|
|
"item_id": item_id.clone(),
|
|
"output_index": output_index,
|
|
"summary_index": summary_index,
|
|
"text": part_text.as_str(),
|
|
}),
|
|
)?);
|
|
out.extend(self.encode_response_event(
|
|
"response.reasoning_summary_part.done",
|
|
json!({
|
|
"type": "response.reasoning_summary_part.done",
|
|
"response_id": self.response_id(),
|
|
"item_id": item_id,
|
|
"output_index": output_index,
|
|
"summary_index": summary_index,
|
|
"part": {
|
|
"type": "summary_text",
|
|
"text": part_text.as_str(),
|
|
}
|
|
}),
|
|
)?);
|
|
self.reasoning_summary_parts.push(part_text);
|
|
self.reasoning_part.clear();
|
|
self.reasoning_part_started = false;
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::ReasoningSignature(_) => Ok(Vec::new()),
|
|
CanonicalStreamEvent::ContentPart(part) => {
|
|
let placeholder = openai_stream_placeholder_for_content_part(&part);
|
|
let mut out = self.ensure_text_item_started()?;
|
|
self.text.push_str(&placeholder);
|
|
out.extend(self.encode_response_event(
|
|
"response.output_text.delta",
|
|
json!({
|
|
"type": "response.output_text.delta",
|
|
"response_id": self.response_id(),
|
|
"output_index": self.message_output_index.unwrap_or(0),
|
|
"item_id": self.message_item_id(),
|
|
"content_index": 0,
|
|
"delta": placeholder,
|
|
}),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::ImageGenerationCall { index, item } => {
|
|
self.emit_image_generation_call_item(index, item)
|
|
}
|
|
CanonicalStreamEvent::ToolCallStart {
|
|
index,
|
|
call_id,
|
|
name,
|
|
} => {
|
|
let mut out = self.ensure_started()?;
|
|
let output_index = self.ensure_tool_output_index(index);
|
|
let response_id = self.response_id().to_string();
|
|
let state = self.tool_calls.entry(index).or_default();
|
|
state.call_id = call_id.clone();
|
|
state.name = name.clone();
|
|
state.web_search = is_responses_web_search_tool(&name);
|
|
let emitted_call_id = state.call_id.clone();
|
|
let emitted_name = state.name.clone();
|
|
let item = if state.web_search {
|
|
json!({
|
|
"type": "web_search_call",
|
|
"id": emitted_call_id,
|
|
"status": "in_progress",
|
|
"action": {
|
|
"type": "search",
|
|
"query": "",
|
|
},
|
|
})
|
|
} else {
|
|
json!({
|
|
"type": "function_call",
|
|
"id": call_id,
|
|
"call_id": emitted_call_id,
|
|
"name": emitted_name,
|
|
"arguments": "",
|
|
"status": "in_progress",
|
|
})
|
|
};
|
|
out.extend(self.encode_response_event(
|
|
"response.output_item.added",
|
|
json!({
|
|
"type": "response.output_item.added",
|
|
"response_id": response_id,
|
|
"output_index": output_index,
|
|
"item": item
|
|
}),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
|
let mut out = self.ensure_started()?;
|
|
let output_index = self.ensure_tool_output_index(index);
|
|
let response_id = self.response_id().to_string();
|
|
let state = self.tool_calls.entry(index).or_default();
|
|
state.arguments.push_str(&arguments);
|
|
if state.web_search {
|
|
return Ok(out);
|
|
}
|
|
let item_id = if state.call_id.is_empty() {
|
|
build_generated_tool_call_id(index)
|
|
} else {
|
|
state.call_id.clone()
|
|
};
|
|
out.extend(self.encode_response_event(
|
|
"response.function_call_arguments.delta",
|
|
json!({
|
|
"type": "response.function_call_arguments.delta",
|
|
"response_id": response_id,
|
|
"output_index": output_index,
|
|
"item_id": item_id.clone(),
|
|
"call_id": item_id,
|
|
"delta": arguments,
|
|
}),
|
|
)?);
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::ToolResultDelta {
|
|
index,
|
|
tool_use_id,
|
|
name,
|
|
content,
|
|
} => {
|
|
let mut out = self.ensure_started()?;
|
|
let output_index = self.ensure_tool_result_output_index(index);
|
|
let response_id = self.response_id().to_string();
|
|
let state = self.tool_results.entry(index).or_default();
|
|
if state.tool_use_id.is_empty() {
|
|
state.tool_use_id = tool_use_id.clone();
|
|
}
|
|
if name.is_some() {
|
|
state.name = name.clone();
|
|
}
|
|
let emitted_tool_use_id = if state.tool_use_id.is_empty() {
|
|
tool_use_id
|
|
} else {
|
|
state.tool_use_id.clone()
|
|
};
|
|
if !state.item_started {
|
|
let mut item = Map::new();
|
|
item.insert(
|
|
"type".to_string(),
|
|
Value::String("function_call_output".to_string()),
|
|
);
|
|
item.insert(
|
|
"id".to_string(),
|
|
Value::String(format!("{emitted_tool_use_id}_output")),
|
|
);
|
|
item.insert(
|
|
"call_id".to_string(),
|
|
Value::String(emitted_tool_use_id.clone()),
|
|
);
|
|
if let Some(name) = state
|
|
.name
|
|
.as_ref()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.cloned()
|
|
{
|
|
item.insert("name".to_string(), Value::String(name));
|
|
}
|
|
item.insert("output".to_string(), Value::String(String::new()));
|
|
out.extend(self.encode_response_event(
|
|
"response.output_item.added",
|
|
json!({
|
|
"type": "response.output_item.added",
|
|
"response_id": response_id,
|
|
"output_index": output_index,
|
|
"item": Value::Object(item),
|
|
}),
|
|
)?);
|
|
self.tool_results.entry(index).or_default().item_started = true;
|
|
}
|
|
self.tool_results
|
|
.entry(index)
|
|
.or_default()
|
|
.content
|
|
.push_str(&content);
|
|
if !content.is_empty() {
|
|
out.extend(self.encode_response_event(
|
|
"response.function_call_output.delta",
|
|
json!({
|
|
"type": "response.function_call_output.delta",
|
|
"response_id": self.response_id(),
|
|
"output_index": output_index,
|
|
"item_id": format!("{emitted_tool_use_id}_output"),
|
|
"call_id": emitted_tool_use_id,
|
|
"delta": content,
|
|
}),
|
|
)?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
|
|
CanonicalStreamEvent::Finish { usage, .. } => {
|
|
if self.finished {
|
|
return Ok(Vec::new());
|
|
}
|
|
let mut out = self.ensure_started()?;
|
|
out.extend(self.finish_reasoning_item()?);
|
|
out.extend(self.finish_text_item()?);
|
|
out.extend(self.finish_tool_items()?);
|
|
out.extend(self.finish_tool_result_items()?);
|
|
let usage = usage.unwrap_or_default();
|
|
out.extend(self.encode_response_event(
|
|
"response.completed",
|
|
json!({
|
|
"type": "response.completed",
|
|
"response": self.completed_response(usage),
|
|
}),
|
|
)?);
|
|
self.finished = true;
|
|
Ok(out)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn emit_error(&mut self, error_body: Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
let Some(error) = error_body.get("error").cloned() else {
|
|
return Ok(Vec::new());
|
|
};
|
|
self.finished = true;
|
|
self.encode_response_event(
|
|
"response.failed",
|
|
json!({
|
|
"type": "response.failed",
|
|
"error": error,
|
|
}),
|
|
)
|
|
}
|
|
|
|
pub fn finish(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
|
if !self.started || self.finished {
|
|
return Ok(Vec::new());
|
|
}
|
|
self.emit(CanonicalStreamFrame {
|
|
id: self
|
|
.response_id
|
|
.clone()
|
|
.unwrap_or_else(|| "resp-local-stream".to_string()),
|
|
model: self.model.clone().unwrap_or_else(|| "unknown".to_string()),
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason: None,
|
|
usage: None,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
fn openai_stream_placeholder_for_content_part(part: &CanonicalContentPart) -> String {
|
|
match part {
|
|
CanonicalContentPart::ImageUrl(url) => {
|
|
if url.starts_with("data:") {
|
|
"[Image]".to_string()
|
|
} else {
|
|
format!("[Image: {url}]")
|
|
}
|
|
}
|
|
CanonicalContentPart::File {
|
|
reference,
|
|
mime_type,
|
|
filename,
|
|
..
|
|
} => reference
|
|
.as_ref()
|
|
.map(|value| format!("[File: {value}]"))
|
|
.or_else(|| filename.as_ref().map(|value| format!("[File: {value}]")))
|
|
.or_else(|| mime_type.as_ref().map(|value| format!("[File: {value}]")))
|
|
.unwrap_or_else(|| "[File]".to_string()),
|
|
CanonicalContentPart::Audio { format, .. } => format!("[Audio: {format}]"),
|
|
}
|
|
}
|
|
|
|
fn openai_tool_result_content_from_value(value: Option<&Value>) -> String {
|
|
match value {
|
|
Some(Value::String(text)) => text.clone(),
|
|
Some(Value::Null) | None => String::new(),
|
|
Some(value) => value.to_string(),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn data_line(value: Value) -> Vec<u8> {
|
|
format!("data: {}\n", value).into_bytes()
|
|
}
|
|
|
|
fn response_sequence_numbers(sse: &str) -> Vec<u64> {
|
|
let mut sequence_numbers = Vec::new();
|
|
for payload in sse.lines().filter_map(|line| line.strip_prefix("data: ")) {
|
|
let Ok(value) = serde_json::from_str::<Value>(payload) else {
|
|
continue;
|
|
};
|
|
let Some(ty) = value.get("type").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
if !ty.starts_with("response.") {
|
|
continue;
|
|
}
|
|
if let Some(sequence_number) = value.get("sequence_number").and_then(Value::as_u64) {
|
|
sequence_numbers.push(sequence_number);
|
|
}
|
|
}
|
|
sequence_numbers
|
|
}
|
|
|
|
fn response_reasoning_text_done_parts(sse: &str) -> Vec<(u64, String)> {
|
|
let mut parts = Vec::new();
|
|
for block in sse.split("\n\n") {
|
|
let mut event_name = None;
|
|
let mut data = None;
|
|
for line in block.lines() {
|
|
if let Some(value) = line.strip_prefix("event: ") {
|
|
event_name = Some(value);
|
|
} else if let Some(value) = line.strip_prefix("data: ") {
|
|
data = Some(value);
|
|
}
|
|
}
|
|
if event_name != Some("response.reasoning_summary_text.done") {
|
|
continue;
|
|
}
|
|
let Some(data) = data else {
|
|
continue;
|
|
};
|
|
let Ok(value) = serde_json::from_str::<Value>(data) else {
|
|
continue;
|
|
};
|
|
let Some(summary_index) = value.get("summary_index").and_then(Value::as_u64) else {
|
|
continue;
|
|
};
|
|
let Some(text) = value.get("text").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
parts.push((summary_index, text.to_string()));
|
|
}
|
|
parts
|
|
}
|
|
|
|
#[test]
|
|
fn openai_chat_provider_state_emits_unknown_events_for_unrecognized_deltas() {
|
|
let mut state = OpenAIChatProviderState::default();
|
|
let report_context = json!({});
|
|
let frames = state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"id": "chatcmpl_unknown_123",
|
|
"model": "gpt-5.4",
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": {
|
|
"future_delta_type": {
|
|
"payload": true
|
|
}
|
|
}
|
|
}]
|
|
})),
|
|
)
|
|
.expect("unknown delta should parse");
|
|
|
|
assert!(frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::UnknownEvent(ref payload)
|
|
if payload.get("delta")
|
|
.and_then(|delta| delta.get("future_delta_type"))
|
|
.is_some()
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_provider_state_emits_unknown_events_for_unknown_response_types() {
|
|
let mut state = OpenAIResponsesProviderState::default();
|
|
let report_context = json!({});
|
|
let frames = state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.future.delta",
|
|
"response": {
|
|
"id": "resp_unknown_123",
|
|
"model": "gpt-5.4"
|
|
},
|
|
"delta": {
|
|
"payload": true
|
|
}
|
|
})),
|
|
)
|
|
.expect("unknown response event should parse");
|
|
|
|
assert!(frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::UnknownEvent(ref payload)
|
|
if payload.get("type").and_then(Value::as_str) == Some("response.future.delta")
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_usage_derives_missing_input_tokens_from_total() {
|
|
let usage = canonical_usage_from_openai_usage(Some(&json!({
|
|
"output_tokens": 177,
|
|
"output_tokens_details": {
|
|
"reasoning_tokens": 7,
|
|
},
|
|
"total_tokens": 20_612,
|
|
"input_tokens_details": {
|
|
"cached_tokens": 19_840,
|
|
},
|
|
})))
|
|
.expect("usage should parse");
|
|
|
|
assert_eq!(usage.input_tokens, 20_435);
|
|
assert_eq!(usage.output_tokens, 177);
|
|
assert_eq!(usage.cache_read_tokens, 19_840);
|
|
assert_eq!(usage.reasoning_tokens, 7);
|
|
}
|
|
|
|
#[test]
|
|
fn openai_chat_provider_state_accepts_usage_only_terminal_chunk() {
|
|
let mut state = OpenAIChatProviderState::default();
|
|
let report_context = json!({});
|
|
let _ = state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"id": "chatcmpl_123",
|
|
"object": "chat.completion.chunk",
|
|
"model": "gpt-5.4",
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": {},
|
|
"finish_reason": "stop",
|
|
}],
|
|
})),
|
|
)
|
|
.expect("finish chunk should parse");
|
|
let frames = state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"usage": {
|
|
"input_tokens": 26,
|
|
"input_tokens_details": {
|
|
"cached_tokens": 0,
|
|
},
|
|
"output_tokens": 144,
|
|
"output_tokens_details": {
|
|
"reasoning_tokens": 10,
|
|
},
|
|
"total_tokens": 170,
|
|
},
|
|
})),
|
|
)
|
|
.expect("usage-only chunk should parse");
|
|
|
|
assert!(frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::Finish {
|
|
finish_reason: Some(ref reason),
|
|
usage: Some(CanonicalUsage {
|
|
input_tokens: 26,
|
|
output_tokens: 144,
|
|
cache_read_tokens: 0,
|
|
reasoning_tokens: 10,
|
|
..
|
|
}),
|
|
} if reason == "stop"
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_provider_state_extracts_response_completed_usage() {
|
|
let mut state = OpenAIResponsesProviderState::default();
|
|
let report_context = json!({});
|
|
let frames = state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.completed",
|
|
"response": {
|
|
"id": "resp_063494bbd780be940169eb8191c4ec8191916347b2080805ee",
|
|
"object": "response",
|
|
"model": "gpt-5.5",
|
|
"status": "completed",
|
|
"output": [],
|
|
"usage": {
|
|
"input_tokens": 26,
|
|
"input_tokens_details": {
|
|
"cached_tokens": 0,
|
|
},
|
|
"output_tokens": 137,
|
|
"output_tokens_details": {
|
|
"reasoning_tokens": 0,
|
|
},
|
|
"total_tokens": 163,
|
|
},
|
|
},
|
|
"sequence_number": 139,
|
|
})),
|
|
)
|
|
.expect("completed event should parse");
|
|
|
|
assert!(frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::Finish {
|
|
usage: Some(CanonicalUsage {
|
|
input_tokens: 26,
|
|
output_tokens: 137,
|
|
cache_read_tokens: 0,
|
|
..
|
|
}),
|
|
..
|
|
}
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_client_emitter_emits_doc_like_text_events() {
|
|
let mut emitter = OpenAIResponsesClientEmitter::default();
|
|
let start = CanonicalStreamFrame {
|
|
id: "chatcmpl_stream_123".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::Start,
|
|
};
|
|
let text = CanonicalStreamFrame {
|
|
id: "chatcmpl_stream_123".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::TextDelta("Hello".to_string()),
|
|
};
|
|
let finish = CanonicalStreamFrame {
|
|
id: "chatcmpl_stream_123".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason: Some("stop".to_string()),
|
|
usage: Some(CanonicalUsage {
|
|
input_tokens: 1,
|
|
output_tokens: 2,
|
|
total_tokens: 3,
|
|
..CanonicalUsage::default()
|
|
}),
|
|
},
|
|
};
|
|
|
|
let mut bytes = emitter.emit(start).expect("start should encode");
|
|
bytes.extend(emitter.emit(text).expect("text should encode"));
|
|
bytes.extend(emitter.emit(finish).expect("finish should encode"));
|
|
|
|
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
|
assert!(sse.contains("event: response.created\n"));
|
|
assert!(sse.contains("event: response.in_progress\n"));
|
|
assert!(sse.contains("event: response.output_item.added\n"));
|
|
assert!(sse.contains("event: response.content_part.added\n"));
|
|
assert!(sse.contains("event: response.output_text.delta\n"));
|
|
assert!(sse.contains("event: response.output_text.done\n"));
|
|
assert!(sse.contains("event: response.content_part.done\n"));
|
|
assert!(sse.contains("event: response.output_item.done\n"));
|
|
assert!(sse.contains("event: response.completed\n"));
|
|
assert!(sse.contains("\"response_id\":\"resp_stream_123\""));
|
|
assert!(sse.contains("\"item_id\":\"resp_stream_123_msg\""));
|
|
assert!(sse.contains("\"text\":\"Hello\""));
|
|
assert_eq!(response_sequence_numbers(&sse), (1..=9).collect::<Vec<_>>());
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_client_emitter_keeps_text_item_id_stable_after_text_started() {
|
|
let mut emitter = OpenAIResponsesClientEmitter::default();
|
|
let mut bytes = emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "msg_first".to_string(),
|
|
model: "claude-haiku-4-5-20251001".to_string(),
|
|
event: CanonicalStreamEvent::TextDelta("Hel".to_string()),
|
|
})
|
|
.expect("first text should encode");
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "msg_second".to_string(),
|
|
model: "claude-haiku-4-5-20251001".to_string(),
|
|
event: CanonicalStreamEvent::TextDelta("lo".to_string()),
|
|
})
|
|
.expect("second text should encode"),
|
|
);
|
|
|
|
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
|
assert!(sse.contains("\"item_id\":\"msg_first_msg\""));
|
|
assert!(!sse.contains("\"item_id\":\"msg_second_msg\""));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_provider_state_accepts_done_events_without_deltas() {
|
|
let mut state = OpenAIResponsesProviderState::default();
|
|
let report_context = json!({});
|
|
let mut frames = Vec::new();
|
|
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.created",
|
|
"response": {
|
|
"id": "resp_123",
|
|
"model": "gpt-5.4",
|
|
}
|
|
})),
|
|
)
|
|
.expect("created should parse"),
|
|
);
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.output_text.done",
|
|
"response_id": "resp_123",
|
|
"output_index": 0,
|
|
"item_id": "resp_123_msg",
|
|
"content_index": 0,
|
|
"text": "Hello",
|
|
})),
|
|
)
|
|
.expect("text done should parse"),
|
|
);
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.function_call_arguments.done",
|
|
"response_id": "resp_123",
|
|
"output_index": 1,
|
|
"item_id": "call_123",
|
|
"call_id": "call_123",
|
|
"arguments": "{\"city\":\"SF\"}",
|
|
})),
|
|
)
|
|
.expect("arguments done should parse"),
|
|
);
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.completed",
|
|
"response": {
|
|
"id": "resp_123",
|
|
"object": "response",
|
|
"model": "gpt-5.4",
|
|
"status": "completed",
|
|
"output": [{
|
|
"type": "message",
|
|
"id": "resp_123_msg",
|
|
"role": "assistant",
|
|
"status": "completed",
|
|
"content": [{
|
|
"type": "output_text",
|
|
"text": "Hello",
|
|
"annotations": [],
|
|
}]
|
|
}, {
|
|
"type": "function_call",
|
|
"id": "call_123",
|
|
"call_id": "call_123",
|
|
"name": "get_weather",
|
|
"arguments": "{\"city\":\"SF\"}",
|
|
}],
|
|
"usage": {
|
|
"input_tokens": 1,
|
|
"output_tokens": 2,
|
|
"total_tokens": 3,
|
|
}
|
|
}
|
|
})),
|
|
)
|
|
.expect("completed should parse"),
|
|
);
|
|
|
|
assert!(matches!(
|
|
frames.first().map(|frame| &frame.event),
|
|
Some(CanonicalStreamEvent::Start)
|
|
));
|
|
assert!(frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::TextDelta(ref text) if text == "Hello"
|
|
)));
|
|
assert!(frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::ToolCallStart { ref call_id, .. } if call_id == "call_123"
|
|
)));
|
|
assert!(frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::ToolCallArgumentsDelta { ref arguments, .. }
|
|
if arguments == "{\"city\":\"SF\"}"
|
|
)));
|
|
assert!(frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::Finish {
|
|
finish_reason: Some(ref reason),
|
|
usage: Some(CanonicalUsage {
|
|
input_tokens: 1,
|
|
output_tokens: 2,
|
|
total_tokens: 3,
|
|
..
|
|
}),
|
|
} if reason == "tool_calls"
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_provider_state_parses_function_call_output_as_tool_result() {
|
|
let mut state = OpenAIResponsesProviderState::default();
|
|
let report_context = json!({});
|
|
let mut frames = Vec::new();
|
|
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.created",
|
|
"response": {
|
|
"id": "resp_123",
|
|
"model": "gpt-5.4",
|
|
}
|
|
})),
|
|
)
|
|
.expect("created should parse"),
|
|
);
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.function_call_output.done",
|
|
"response_id": "resp_123",
|
|
"output_index": 2,
|
|
"call_id": "call_123",
|
|
"name": "lookup",
|
|
"output": {"ok": true},
|
|
})),
|
|
)
|
|
.expect("tool result should parse"),
|
|
);
|
|
|
|
assert!(frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::ToolResultDelta {
|
|
index: 2,
|
|
ref tool_use_id,
|
|
name: Some(ref name),
|
|
ref content,
|
|
} if tool_use_id == "call_123" && name == "lookup" && content == "{\"ok\":true}"
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_provider_state_preserves_image_generation_calls() {
|
|
let mut state = OpenAIResponsesProviderState::default();
|
|
let report_context = json!({});
|
|
let frames = state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.completed",
|
|
"response": {
|
|
"id": "resp_img_123",
|
|
"model": "gpt-image-2",
|
|
"output": [{
|
|
"id": "ig_123",
|
|
"type": "image_generation_call",
|
|
"status": "completed",
|
|
"output_format": "png",
|
|
"result": "aGVsbG8="
|
|
}],
|
|
"usage": {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}
|
|
}
|
|
})),
|
|
)
|
|
.expect("completed event should parse");
|
|
|
|
assert!(frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::ImageGenerationCall {
|
|
index: 0,
|
|
ref item,
|
|
} if item["type"] == json!("image_generation_call")
|
|
&& item["result"] == json!("aGVsbG8=")
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_provider_state_waits_for_final_image_generation_item() {
|
|
let mut state = OpenAIResponsesProviderState::default();
|
|
let report_context = json!({});
|
|
|
|
let added_frames = state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.output_item.added",
|
|
"output_index": 0,
|
|
"item": {
|
|
"id": "ig_123",
|
|
"type": "image_generation_call",
|
|
"status": "generating",
|
|
"output_format": "png",
|
|
"result": "early"
|
|
}
|
|
})),
|
|
)
|
|
.expect("added event should parse");
|
|
|
|
assert!(!added_frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::ImageGenerationCall { .. }
|
|
)));
|
|
|
|
let done_frames = state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.output_item.done",
|
|
"output_index": 0,
|
|
"item": {
|
|
"id": "ig_123",
|
|
"type": "image_generation_call",
|
|
"status": "completed",
|
|
"output_format": "png",
|
|
"result": "final"
|
|
}
|
|
})),
|
|
)
|
|
.expect("done event should parse");
|
|
|
|
assert!(done_frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::ImageGenerationCall {
|
|
index: 0,
|
|
ref item,
|
|
} if item["status"] == json!("completed") && item["result"] == json!("final")
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_client_emitter_emits_image_generation_call_events() {
|
|
let mut emitter = OpenAIResponsesClientEmitter::default();
|
|
let mut bytes = emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_img_123".to_string(),
|
|
model: "gpt-image-2".to_string(),
|
|
event: CanonicalStreamEvent::ImageGenerationCall {
|
|
index: 0,
|
|
item: json!({
|
|
"id": "ig_123",
|
|
"type": "image_generation_call",
|
|
"status": "completed",
|
|
"output_format": "png",
|
|
"result": "aGVsbG8="
|
|
}),
|
|
},
|
|
})
|
|
.expect("image event should encode");
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_img_123".to_string(),
|
|
model: "gpt-image-2".to_string(),
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason: Some("stop".to_string()),
|
|
usage: None,
|
|
},
|
|
})
|
|
.expect("finish should encode"),
|
|
);
|
|
|
|
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
|
assert!(sse.contains("event: response.output_item.done\n"));
|
|
assert!(sse.contains("\"type\":\"image_generation_call\""));
|
|
assert!(sse.contains("\"result\":\"aGVsbG8=\""));
|
|
assert!(sse.contains("\"output\":["));
|
|
assert!(sse.contains("\"id\":\"ig_123\""));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_client_emitter_emits_function_call_output_events() {
|
|
let mut emitter = OpenAIResponsesClientEmitter::default();
|
|
let mut bytes = emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_123".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::ToolResultDelta {
|
|
index: 1,
|
|
tool_use_id: "call_123".to_string(),
|
|
name: Some("lookup".to_string()),
|
|
content: "{\"ok\":true}".to_string(),
|
|
},
|
|
})
|
|
.expect("tool result should encode");
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_123".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason: Some("stop".to_string()),
|
|
usage: None,
|
|
},
|
|
})
|
|
.expect("finish should encode"),
|
|
);
|
|
|
|
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
|
assert!(sse.contains("event: response.function_call_output.delta\n"));
|
|
assert!(sse.contains("\"type\":\"function_call_output\""));
|
|
assert!(sse.contains("\"call_id\":\"call_123\""));
|
|
assert!(sse.contains("\"output\":\"{\\\"ok\\\":true}\""));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_client_emitter_emits_web_search_call_item() {
|
|
let mut emitter = OpenAIResponsesClientEmitter::default();
|
|
let mut bytes = emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_123".to_string(),
|
|
model: "gpt-5-5-low".to_string(),
|
|
event: CanonicalStreamEvent::ToolCallStart {
|
|
index: 0,
|
|
call_id: "call_ws_1".to_string(),
|
|
name: "web_search".to_string(),
|
|
},
|
|
})
|
|
.expect("tool start should encode");
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_123".to_string(),
|
|
model: "gpt-5-5-low".to_string(),
|
|
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
|
index: 0,
|
|
arguments: r#"{"query":"today tech"}"#.to_string(),
|
|
},
|
|
})
|
|
.expect("arguments should encode"),
|
|
);
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_123".to_string(),
|
|
model: "gpt-5-5-low".to_string(),
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason: Some("tool_calls".to_string()),
|
|
usage: None,
|
|
},
|
|
})
|
|
.expect("finish should encode"),
|
|
);
|
|
|
|
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
|
assert!(sse.contains("event: response.output_item.added\n"));
|
|
assert!(sse.contains(r#""type":"web_search_call""#));
|
|
assert!(sse.contains(r#""status":"in_progress""#));
|
|
assert!(sse.contains(r#""query":"""#));
|
|
assert!(sse.contains(r#""type":"search""#));
|
|
assert!(sse.contains("event: response.output_item.done\n"));
|
|
assert!(sse.contains(r#""query":"today tech""#));
|
|
assert!(!sse.contains("response.function_call_arguments.delta"));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_provider_state_accepts_legacy_outtext_delta_alias() {
|
|
let mut state = OpenAIResponsesProviderState::default();
|
|
let report_context = json!({});
|
|
let mut frames = Vec::new();
|
|
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.created",
|
|
"response": {
|
|
"id": "resp_legacy_123",
|
|
"model": "gpt-5.4",
|
|
}
|
|
})),
|
|
)
|
|
.expect("created should parse"),
|
|
);
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.outtext.delta",
|
|
"response_id": "resp_legacy_123",
|
|
"output_index": 0,
|
|
"item_id": "resp_legacy_123_msg",
|
|
"content_index": 0,
|
|
"delta": "Hello from legacy alias",
|
|
})),
|
|
)
|
|
.expect("legacy text delta should parse"),
|
|
);
|
|
|
|
assert!(frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::TextDelta(ref text) if text == "Hello from legacy alias"
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_chat_client_emitter_emits_reasoning_content_chunks() {
|
|
let mut emitter = OpenAIChatClientEmitter::default();
|
|
let mut bytes = emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "chatcmpl_123".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::Start,
|
|
})
|
|
.expect("start should encode");
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "chatcmpl_123".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::ReasoningDelta("because".to_string()),
|
|
})
|
|
.expect("reasoning should encode"),
|
|
);
|
|
|
|
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
|
assert!(sse.contains("\"reasoning_content\":\"because\""));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_chat_client_emitter_renders_image_parts_as_placeholder() {
|
|
let mut emitter = OpenAIChatClientEmitter::default();
|
|
let bytes = emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "chatcmpl_img_123".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::ContentPart(CanonicalContentPart::ImageUrl(
|
|
"data:image/png;base64,iVBORw0KGgo=".to_string(),
|
|
)),
|
|
})
|
|
.expect("image should encode");
|
|
|
|
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
|
assert!(sse.contains("[Image]"));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_chat_client_emitter_emits_usage_only_final_chunk() {
|
|
let mut emitter = OpenAIChatClientEmitter::default();
|
|
let mut bytes = emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "chatcmpl_789".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::Start,
|
|
})
|
|
.expect("start should encode");
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "chatcmpl_789".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::TextDelta("Hello".to_string()),
|
|
})
|
|
.expect("text should encode"),
|
|
);
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "chatcmpl_789".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason: Some("stop".to_string()),
|
|
usage: Some(CanonicalUsage {
|
|
input_tokens: 1,
|
|
output_tokens: 2,
|
|
total_tokens: 3,
|
|
cache_creation_tokens: 5,
|
|
cache_read_tokens: 4,
|
|
reasoning_tokens: 1,
|
|
..CanonicalUsage::default()
|
|
}),
|
|
},
|
|
})
|
|
.expect("finish should encode"),
|
|
);
|
|
|
|
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
|
assert!(sse.contains("\"finish_reason\":\"stop\""));
|
|
assert!(sse.contains("\"choices\":[]"));
|
|
assert!(sse.contains("\"prompt_tokens\":1"));
|
|
assert!(sse.contains("\"completion_tokens\":2"));
|
|
assert!(sse.contains("\"completion_tokens_details\":{\"reasoning_tokens\":1}"));
|
|
assert!(sse.contains("\"cached_creation_tokens\":5"));
|
|
assert!(sse.contains("\"cached_tokens\":4"));
|
|
assert!(sse.contains("\"total_tokens\":3"));
|
|
assert!(sse.contains("data: [DONE]\n\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_chat_provider_state_accepts_usage_only_final_chunk() {
|
|
let mut state = OpenAIChatProviderState::default();
|
|
let report_context = json!({});
|
|
let mut frames = state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"id": "chatcmpl_456",
|
|
"object": "chat.completion.chunk",
|
|
"model": "gpt-5.4",
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": {
|
|
"role": "assistant",
|
|
"content": "Hello",
|
|
},
|
|
"finish_reason": Value::Null,
|
|
}]
|
|
})),
|
|
)
|
|
.expect("first chunk should parse");
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"id": "chatcmpl_456",
|
|
"object": "chat.completion.chunk",
|
|
"model": "gpt-5.4",
|
|
"choices": [{
|
|
"index": 0,
|
|
"delta": {},
|
|
"finish_reason": "stop",
|
|
}],
|
|
"usage": {},
|
|
})),
|
|
)
|
|
.expect("stop chunk should parse"),
|
|
);
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"id": "chatcmpl_456",
|
|
"object": "chat.completion.chunk",
|
|
"model": "gpt-5.4",
|
|
"choices": [],
|
|
"usage": {
|
|
"prompt_tokens": 1,
|
|
"completion_tokens": 2,
|
|
"total_tokens": 3,
|
|
}
|
|
})),
|
|
)
|
|
.expect("usage chunk should parse"),
|
|
);
|
|
|
|
assert!(frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::Finish {
|
|
finish_reason: Some(ref reason),
|
|
usage: Some(CanonicalUsage {
|
|
input_tokens: 1,
|
|
output_tokens: 2,
|
|
total_tokens: 3,
|
|
..
|
|
}),
|
|
} if reason == "stop"
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_client_emitter_includes_reasoning_in_completed_response() {
|
|
let mut emitter = OpenAIResponsesClientEmitter::default();
|
|
let mut bytes = emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_123".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::Start,
|
|
})
|
|
.expect("start should encode");
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_123".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::ReasoningDelta("because".to_string()),
|
|
})
|
|
.expect("reasoning should encode"),
|
|
);
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_123".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason: Some("stop".to_string()),
|
|
usage: Some(CanonicalUsage {
|
|
input_tokens: 1,
|
|
output_tokens: 2,
|
|
total_tokens: 3,
|
|
cache_creation_tokens: 5,
|
|
cache_read_tokens: 4,
|
|
reasoning_tokens: 1,
|
|
..CanonicalUsage::default()
|
|
}),
|
|
},
|
|
})
|
|
.expect("finish should encode"),
|
|
);
|
|
|
|
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
|
assert!(sse.contains("\"type\":\"reasoning\""));
|
|
assert!(sse.contains("\"text\":\"because\""));
|
|
assert!(sse.contains("\"output_tokens_details\":{\"reasoning_tokens\":1}"));
|
|
assert!(sse.contains("\"input_tokens_details\""));
|
|
assert!(sse.contains("\"cached_creation_tokens\":5"));
|
|
assert!(sse.contains("\"cached_tokens\":4"));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_client_emitter_emits_doc_like_reasoning_events() {
|
|
let mut emitter = OpenAIResponsesClientEmitter::default();
|
|
let mut bytes = emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_456".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::Start,
|
|
})
|
|
.expect("start should encode");
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_456".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::ReasoningDelta("step".to_string()),
|
|
})
|
|
.expect("reasoning should encode"),
|
|
);
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_456".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason: Some("stop".to_string()),
|
|
usage: None,
|
|
},
|
|
})
|
|
.expect("finish should encode"),
|
|
);
|
|
|
|
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
|
assert!(sse.contains("event: response.reasoning_summary_part.added\n"));
|
|
assert!(sse.contains("event: response.reasoning_summary_text.delta\n"));
|
|
assert!(sse.contains("event: response.reasoning_summary_text.done\n"));
|
|
assert!(sse.contains("event: response.reasoning_summary_part.done\n"));
|
|
assert!(sse.contains("\"item_id\":\"resp_456_rs_0\""));
|
|
assert!(sse.contains("\"type\":\"reasoning\""));
|
|
assert_eq!(response_sequence_numbers(&sse), (1..=9).collect::<Vec<_>>());
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_client_emitter_closes_distinct_reasoning_parts() {
|
|
let mut emitter = OpenAIResponsesClientEmitter::default();
|
|
let mut bytes = emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_789".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::Start,
|
|
})
|
|
.expect("start should encode");
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_789".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::ReasoningDelta("alpha".to_string()),
|
|
})
|
|
.expect("first reasoning should encode"),
|
|
);
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_789".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::ReasoningSummaryDone,
|
|
})
|
|
.expect("first boundary should encode"),
|
|
);
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_789".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::ReasoningDelta("beta".to_string()),
|
|
})
|
|
.expect("second reasoning should encode"),
|
|
);
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_789".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::ReasoningSummaryDone,
|
|
})
|
|
.expect("second boundary should encode"),
|
|
);
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_789".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::Finish {
|
|
finish_reason: Some("stop".to_string()),
|
|
usage: None,
|
|
},
|
|
})
|
|
.expect("finish should encode"),
|
|
);
|
|
|
|
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
|
assert_eq!(
|
|
response_reasoning_text_done_parts(&sse),
|
|
vec![(0, "alpha".to_string()), (1, "beta".to_string())]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_client_emitter_emits_failed_event_with_sequence_number() {
|
|
let mut emitter = OpenAIResponsesClientEmitter::default();
|
|
let mut bytes = emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_err_123".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::Start,
|
|
})
|
|
.expect("start should encode");
|
|
bytes.extend(
|
|
emitter
|
|
.emit(CanonicalStreamFrame {
|
|
id: "resp_err_123".to_string(),
|
|
model: "gpt-5.4".to_string(),
|
|
event: CanonicalStreamEvent::TextDelta("Hi".to_string()),
|
|
})
|
|
.expect("text should encode"),
|
|
);
|
|
bytes.extend(
|
|
emitter
|
|
.emit_error(json!({
|
|
"error": {
|
|
"message": "boom",
|
|
"type": "server_error",
|
|
"code": "internal",
|
|
}
|
|
}))
|
|
.expect("error should encode"),
|
|
);
|
|
|
|
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
|
assert!(sse.contains("event: response.failed\n"));
|
|
assert!(sse.contains("\"message\":\"boom\""));
|
|
assert!(!sse.contains("event: response.completed\n"));
|
|
assert_eq!(response_sequence_numbers(&sse), (1..=6).collect::<Vec<_>>());
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_provider_state_accepts_reasoning_summary_events() {
|
|
let mut state = OpenAIResponsesProviderState::default();
|
|
let report_context = json!({});
|
|
let mut frames = Vec::new();
|
|
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.created",
|
|
"response": {
|
|
"id": "resp_456",
|
|
"model": "gpt-5.4",
|
|
}
|
|
})),
|
|
)
|
|
.expect("created should parse"),
|
|
);
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.reasoning_summary_part.added",
|
|
"response_id": "resp_456",
|
|
"item_id": "resp_456_rs_0",
|
|
"output_index": 0,
|
|
"summary_index": 0,
|
|
"part": {
|
|
"type": "summary_text",
|
|
"text": "",
|
|
}
|
|
})),
|
|
)
|
|
.expect("part added should parse"),
|
|
);
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.reasoning_summary_text.delta",
|
|
"response_id": "resp_456",
|
|
"item_id": "resp_456_rs_0",
|
|
"output_index": 0,
|
|
"summary_index": 0,
|
|
"delta": "step",
|
|
})),
|
|
)
|
|
.expect("delta should parse"),
|
|
);
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.reasoning_summary_text.done",
|
|
"response_id": "resp_456",
|
|
"item_id": "resp_456_rs_0",
|
|
"output_index": 0,
|
|
"summary_index": 0,
|
|
"text": "step",
|
|
})),
|
|
)
|
|
.expect("done should parse"),
|
|
);
|
|
|
|
let reasoning = frames
|
|
.iter()
|
|
.filter(|frame| matches!(frame.event, CanonicalStreamEvent::ReasoningDelta(_)))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(reasoning.len(), 1);
|
|
assert!(matches!(
|
|
reasoning[0].event,
|
|
CanonicalStreamEvent::ReasoningDelta(ref text) if text == "step"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_provider_state_accepts_reasoning_done_without_delta() {
|
|
let mut state = OpenAIResponsesProviderState::default();
|
|
let report_context = json!({});
|
|
let mut frames = Vec::new();
|
|
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.created",
|
|
"response": {
|
|
"id": "resp_done_only",
|
|
"model": "gpt-5.4",
|
|
}
|
|
})),
|
|
)
|
|
.expect("created should parse"),
|
|
);
|
|
frames.extend(
|
|
state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.reasoning_summary_text.done",
|
|
"response_id": "resp_done_only",
|
|
"item_id": "resp_done_only_rs_0",
|
|
"output_index": 0,
|
|
"summary_index": 0,
|
|
"text": "fallback reasoning",
|
|
})),
|
|
)
|
|
.expect("reasoning done should parse"),
|
|
);
|
|
|
|
assert!(frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::ReasoningDelta(ref text) if text == "fallback reasoning"
|
|
)));
|
|
assert!(frames
|
|
.iter()
|
|
.any(|frame| matches!(frame.event, CanonicalStreamEvent::ReasoningSummaryDone)));
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_provider_state_does_not_duplicate_part_scoped_reasoning_done() {
|
|
let mut state = OpenAIResponsesProviderState::default();
|
|
let report_context = json!({});
|
|
let mut frames = Vec::new();
|
|
|
|
for event in [
|
|
json!({
|
|
"type": "response.reasoning_summary_text.delta",
|
|
"response_id": "resp_parts",
|
|
"item_id": "resp_parts_rs_0",
|
|
"output_index": 0,
|
|
"summary_index": 0,
|
|
"delta": "alpha",
|
|
}),
|
|
json!({
|
|
"type": "response.reasoning_summary_text.done",
|
|
"response_id": "resp_parts",
|
|
"item_id": "resp_parts_rs_0",
|
|
"output_index": 0,
|
|
"summary_index": 0,
|
|
"text": "alpha",
|
|
}),
|
|
json!({
|
|
"type": "response.reasoning_summary_text.delta",
|
|
"response_id": "resp_parts",
|
|
"item_id": "resp_parts_rs_0",
|
|
"output_index": 0,
|
|
"summary_index": 1,
|
|
"delta": "beta",
|
|
}),
|
|
json!({
|
|
"type": "response.reasoning_summary_text.done",
|
|
"response_id": "resp_parts",
|
|
"item_id": "resp_parts_rs_0",
|
|
"output_index": 0,
|
|
"summary_index": 1,
|
|
"text": "beta",
|
|
}),
|
|
] {
|
|
frames.extend(
|
|
state
|
|
.push_line(&report_context, data_line(event))
|
|
.expect("reasoning event should parse"),
|
|
);
|
|
}
|
|
|
|
let reasoning = frames
|
|
.iter()
|
|
.filter_map(|frame| match &frame.event {
|
|
CanonicalStreamEvent::ReasoningDelta(text) => Some(text.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(reasoning, vec!["alpha", "beta"]);
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_provider_state_does_not_duplicate_added_reasoning_item_summary() {
|
|
let mut state = OpenAIResponsesProviderState::default();
|
|
let report_context = json!({});
|
|
let mut frames = Vec::new();
|
|
|
|
for event in [
|
|
json!({
|
|
"type": "response.output_item.added",
|
|
"response_id": "resp_added_summary",
|
|
"output_index": 0,
|
|
"item": {
|
|
"type": "reasoning",
|
|
"id": "resp_added_summary_rs_0",
|
|
"summary": [{
|
|
"type": "summary_text",
|
|
"text": "alpha",
|
|
}]
|
|
}
|
|
}),
|
|
json!({
|
|
"type": "response.reasoning_summary_text.delta",
|
|
"response_id": "resp_added_summary",
|
|
"item_id": "resp_added_summary_rs_0",
|
|
"output_index": 0,
|
|
"summary_index": 0,
|
|
"delta": "alpha",
|
|
}),
|
|
] {
|
|
frames.extend(
|
|
state
|
|
.push_line(&report_context, data_line(event))
|
|
.expect("reasoning event should parse"),
|
|
);
|
|
}
|
|
|
|
let reasoning = frames
|
|
.iter()
|
|
.filter_map(|frame| match &frame.event {
|
|
CanonicalStreamEvent::ReasoningDelta(text) => Some(text.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(reasoning, vec!["alpha"]);
|
|
}
|
|
|
|
#[test]
|
|
fn openai_responses_provider_state_uses_reasoning_item_as_fallback() {
|
|
let mut state = OpenAIResponsesProviderState::default();
|
|
let report_context = json!({});
|
|
let frames = state
|
|
.push_line(
|
|
&report_context,
|
|
data_line(json!({
|
|
"type": "response.output_item.done",
|
|
"response_id": "resp_item_fallback",
|
|
"output_index": 0,
|
|
"item": {
|
|
"type": "reasoning",
|
|
"id": "resp_item_fallback_rs_0",
|
|
"summary": [{
|
|
"type": "summary_text",
|
|
"text": "item fallback reasoning",
|
|
}]
|
|
}
|
|
})),
|
|
)
|
|
.expect("reasoning item should parse");
|
|
|
|
assert!(frames.iter().any(|frame| matches!(
|
|
frame.event,
|
|
CanonicalStreamEvent::ReasoningDelta(ref text) if text == "item fallback reasoning"
|
|
)));
|
|
}
|
|
}
|