mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 12:40:20 +08:00
Support OpenAI Responses builtin tool stream items
This commit is contained in:
@@ -683,6 +683,136 @@ impl OpenAIResponsesProviderState {
|
||||
self.emit_ready_tool_call(report_context, out, index);
|
||||
}
|
||||
|
||||
fn emit_custom_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("custom_tool_call") {
|
||||
return;
|
||||
}
|
||||
let name = item
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("custom_tool")
|
||||
.to_string();
|
||||
let arguments = tool_arguments_from_maybe_json_string(
|
||||
item.get("input").or_else(|| item.get("arguments")),
|
||||
"input",
|
||||
);
|
||||
self.emit_generic_tool_call_item(report_context, out, item, output_index, name, arguments);
|
||||
}
|
||||
|
||||
fn emit_shell_tool_call_item(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
out: &mut Vec<CanonicalStreamFrame>,
|
||||
item: &Map<String, Value>,
|
||||
output_index: Option<usize>,
|
||||
) {
|
||||
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
|
||||
let name = match item_type {
|
||||
"local_shell_call" => "local_shell",
|
||||
"shell_call" => "shell",
|
||||
_ => return,
|
||||
};
|
||||
let arguments = tool_arguments_from_named_fields(
|
||||
item,
|
||||
&[
|
||||
"action",
|
||||
"environment",
|
||||
"status",
|
||||
"created_by",
|
||||
"max_output_length",
|
||||
],
|
||||
);
|
||||
self.emit_generic_tool_call_item(
|
||||
report_context,
|
||||
out,
|
||||
item,
|
||||
output_index,
|
||||
name.to_string(),
|
||||
arguments,
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_apply_patch_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("apply_patch_call") {
|
||||
return;
|
||||
}
|
||||
let arguments = tool_arguments_from_named_fields(item, &["operation", "status"]);
|
||||
self.emit_generic_tool_call_item(
|
||||
report_context,
|
||||
out,
|
||||
item,
|
||||
output_index,
|
||||
"apply_patch".to_string(),
|
||||
arguments,
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_computer_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("computer_call") {
|
||||
return;
|
||||
}
|
||||
let arguments = tool_arguments_from_named_fields(
|
||||
item,
|
||||
&["action", "actions", "pending_safety_checks", "status"],
|
||||
);
|
||||
self.emit_generic_tool_call_item(
|
||||
report_context,
|
||||
out,
|
||||
item,
|
||||
output_index,
|
||||
"computer".to_string(),
|
||||
arguments,
|
||||
);
|
||||
}
|
||||
|
||||
fn emit_generic_tool_call_item(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
out: &mut Vec<CanonicalStreamFrame>,
|
||||
item: &Map<String, Value>,
|
||||
output_index: Option<usize>,
|
||||
name: String,
|
||||
arguments: String,
|
||||
) {
|
||||
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 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 = name;
|
||||
Self::merge_tool_call_arguments(state, &arguments);
|
||||
self.emit_ready_tool_call(report_context, out, index);
|
||||
}
|
||||
|
||||
fn emit_missing_tool_result(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
@@ -756,6 +886,54 @@ impl OpenAIResponsesProviderState {
|
||||
self.emit_missing_tool_result(report_context, out, index, tool_use_id, name, &content);
|
||||
}
|
||||
|
||||
fn emit_generic_tool_result_item(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
out: &mut Vec<CanonicalStreamFrame>,
|
||||
item: &Map<String, Value>,
|
||||
output_index: Option<usize>,
|
||||
) {
|
||||
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
|
||||
let is_supported_result = matches!(
|
||||
item_type,
|
||||
"custom_tool_call_output"
|
||||
| "local_shell_call_output"
|
||||
| "shell_call_output"
|
||||
| "apply_patch_call_output"
|
||||
| "computer_call_output"
|
||||
);
|
||||
if !is_supported_result {
|
||||
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!("{item_type}:{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 = match item_type {
|
||||
"local_shell_call_output" => Some("local_shell".to_string()),
|
||||
"shell_call_output" => Some("shell".to_string()),
|
||||
"apply_patch_call_output" => Some("apply_patch".to_string()),
|
||||
"computer_call_output" => Some("computer".to_string()),
|
||||
_ => 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,
|
||||
@@ -870,6 +1048,79 @@ impl OpenAIResponsesProviderState {
|
||||
});
|
||||
}
|
||||
|
||||
fn emit_output_item(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
out: &mut Vec<CanonicalStreamFrame>,
|
||||
item: &Map<String, Value>,
|
||||
output_index: Option<usize>,
|
||||
final_item: bool,
|
||||
) {
|
||||
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
|
||||
"function_call" => self.emit_tool_call_item(report_context, out, item, output_index),
|
||||
"function_call_output" => {
|
||||
self.emit_tool_result_item(report_context, out, item, output_index);
|
||||
}
|
||||
"custom_tool_call" => {
|
||||
self.emit_custom_tool_call_item(report_context, out, item, output_index);
|
||||
}
|
||||
"local_shell_call" | "shell_call" => {
|
||||
self.emit_shell_tool_call_item(report_context, out, item, output_index);
|
||||
}
|
||||
"apply_patch_call" => {
|
||||
self.emit_apply_patch_tool_call_item(report_context, out, item, output_index);
|
||||
}
|
||||
"computer_call" => {
|
||||
self.emit_computer_tool_call_item(report_context, out, item, output_index);
|
||||
}
|
||||
"custom_tool_call_output"
|
||||
| "local_shell_call_output"
|
||||
| "shell_call_output"
|
||||
| "apply_patch_call_output"
|
||||
| "computer_call_output" => {
|
||||
self.emit_generic_tool_result_item(report_context, out, item, output_index);
|
||||
}
|
||||
"message" => self.emit_message_item(report_context, out, item, output_index),
|
||||
"reasoning" if final_item => self.emit_reasoning_item(report_context, out, item),
|
||||
"reasoning" => self.ensure_started(report_context, out),
|
||||
"image_generation_call" => {
|
||||
self.emit_image_generation_item(
|
||||
report_context,
|
||||
out,
|
||||
item,
|
||||
output_index,
|
||||
final_item,
|
||||
);
|
||||
}
|
||||
"web_search_call" | "file_search_call" | "code_interpreter_call" | "mcp_call" => {
|
||||
if !final_item {
|
||||
self.ensure_started(report_context, out);
|
||||
}
|
||||
}
|
||||
_ => out.push(self.unknown_frame(report_context, Value::Object(item.clone()))),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_response_output_items(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
out: &mut Vec<CanonicalStreamFrame>,
|
||||
response: &Map<String, Value>,
|
||||
) {
|
||||
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;
|
||||
};
|
||||
self.emit_output_item(report_context, out, item, Some(output_index), true);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_line(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
@@ -988,6 +1239,27 @@ impl OpenAIResponsesProviderState {
|
||||
self.emit_missing_text(report_context, &mut out, key, refusal);
|
||||
}
|
||||
}
|
||||
"response.audio.transcript.delta" => {
|
||||
let piece = value
|
||||
.get("delta")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if !piece.is_empty() {
|
||||
let key = Self::text_part_key_from_event(&value);
|
||||
self.emit_text_delta(report_context, &mut out, key, piece);
|
||||
}
|
||||
}
|
||||
"response.audio.transcript.done" => {
|
||||
let transcript = value
|
||||
.get("transcript")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| value.get("text").and_then(Value::as_str))
|
||||
.unwrap_or_default();
|
||||
if !transcript.is_empty() {
|
||||
let key = Self::text_part_key_from_event(&value);
|
||||
self.emit_missing_text(report_context, &mut out, key, transcript);
|
||||
}
|
||||
}
|
||||
"response.reasoning_text.delta" | "response.reasoning_summary_text.delta" => {
|
||||
let piece = value
|
||||
.get("delta")
|
||||
@@ -1054,32 +1326,70 @@ impl OpenAIResponsesProviderState {
|
||||
.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, output_index);
|
||||
}
|
||||
"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())));
|
||||
}
|
||||
self.emit_output_item(report_context, &mut out, item, output_index, false);
|
||||
}
|
||||
"response.custom_tool_call_input.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 state = self.tool_calls.entry(index).or_default();
|
||||
if state.name.is_empty() {
|
||||
state.name = value
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("custom_tool")
|
||||
.to_string();
|
||||
}
|
||||
state.arguments.push_str(delta);
|
||||
self.emit_ready_tool_call(report_context, &mut out, index);
|
||||
}
|
||||
"response.custom_tool_call_input.done" => {
|
||||
let input = value
|
||||
.get("input")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
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 state = self.tool_calls.entry(index).or_default();
|
||||
if state.name.is_empty() {
|
||||
state.name = value
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("custom_tool")
|
||||
.to_string();
|
||||
}
|
||||
let arguments = tool_arguments_from_maybe_json_string(
|
||||
Some(&Value::String(input.to_string())),
|
||||
"input",
|
||||
);
|
||||
Self::merge_tool_call_arguments(state, &arguments);
|
||||
self.emit_ready_tool_call(report_context, &mut out, index);
|
||||
}
|
||||
"response.function_call_arguments.delta" => {
|
||||
let delta = value
|
||||
@@ -1226,32 +1536,28 @@ impl OpenAIResponsesProviderState {
|
||||
.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, 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,
|
||||
output_index,
|
||||
true,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
|
||||
}
|
||||
}
|
||||
self.emit_output_item(report_context, &mut out, item, output_index, true);
|
||||
}
|
||||
"response.incomplete" => {
|
||||
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);
|
||||
self.emit_response_output_items(report_context, &mut out, response);
|
||||
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
model,
|
||||
event: CanonicalStreamEvent::Finish {
|
||||
finish_reason: Some(openai_responses_incomplete_finish_reason(&value)),
|
||||
usage: canonical_usage_from_openai_usage(response.get("usage")),
|
||||
},
|
||||
});
|
||||
self.finished = true;
|
||||
}
|
||||
event_type if openai_responses_stream_event_is_known_noop(event_type) => {
|
||||
self.ensure_started(report_context, &mut out);
|
||||
}
|
||||
event_type if openai_stream_payload_is_terminal_error(&value) => {
|
||||
self.finished = true;
|
||||
@@ -1276,61 +1582,7 @@ impl OpenAIResponsesProviderState {
|
||||
};
|
||||
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,
|
||||
Some(output_index),
|
||||
);
|
||||
}
|
||||
"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())),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.emit_response_output_items(report_context, &mut out, response);
|
||||
|
||||
let finish_reason = if self.tool_calls.is_empty() {
|
||||
Some("stop".to_string())
|
||||
@@ -2791,6 +3043,95 @@ fn openai_tool_result_content_from_value(value: Option<&Value>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_arguments_from_maybe_json_string(value: Option<&Value>, fallback_key: &str) -> String {
|
||||
match value {
|
||||
Some(Value::String(text)) => {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
match serde_json::from_str::<Value>(trimmed) {
|
||||
Ok(Value::Object(_)) => trimmed.to_string(),
|
||||
Ok(parsed) => single_field_tool_arguments(fallback_key, parsed),
|
||||
Err(_) => single_field_tool_arguments(fallback_key, Value::String(text.clone())),
|
||||
}
|
||||
}
|
||||
Some(value @ Value::Object(_)) => value.to_string(),
|
||||
Some(Value::Null) | None => String::new(),
|
||||
Some(value) => single_field_tool_arguments(fallback_key, value.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn single_field_tool_arguments(key: &str, value: Value) -> String {
|
||||
let mut arguments = Map::new();
|
||||
arguments.insert(key.to_string(), value);
|
||||
Value::Object(arguments).to_string()
|
||||
}
|
||||
|
||||
fn tool_arguments_from_named_fields(item: &Map<String, Value>, field_names: &[&str]) -> String {
|
||||
let mut arguments = Map::new();
|
||||
for field_name in field_names {
|
||||
if let Some(value) = item.get(*field_name) {
|
||||
arguments.insert((*field_name).to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
if arguments.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
Value::Object(arguments).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_responses_stream_event_is_known_noop(event_type: &str) -> bool {
|
||||
matches!(
|
||||
event_type,
|
||||
"response.queued"
|
||||
| "response.output_text.annotation.added"
|
||||
| "response.audio.delta"
|
||||
| "response.audio.done"
|
||||
| "response.code_interpreter_call.in_progress"
|
||||
| "response.code_interpreter_call.interpreting"
|
||||
| "response.code_interpreter_call.completed"
|
||||
| "response.code_interpreter_call_code.delta"
|
||||
| "response.code_interpreter_call_code.done"
|
||||
| "response.file_search_call.in_progress"
|
||||
| "response.file_search_call.searching"
|
||||
| "response.file_search_call.completed"
|
||||
| "response.image_generation_call.in_progress"
|
||||
| "response.image_generation_call.generating"
|
||||
| "response.image_generation_call.partial_image"
|
||||
| "response.image_generation_call.completed"
|
||||
| "response.mcp_call.in_progress"
|
||||
| "response.mcp_call.completed"
|
||||
| "response.mcp_call.failed"
|
||||
| "response.mcp_call_arguments.delta"
|
||||
| "response.mcp_call_arguments.done"
|
||||
| "response.mcp_list_tools.in_progress"
|
||||
| "response.mcp_list_tools.completed"
|
||||
| "response.mcp_list_tools.failed"
|
||||
| "response.web_search_call.in_progress"
|
||||
| "response.web_search_call.searching"
|
||||
| "response.web_search_call.completed"
|
||||
)
|
||||
}
|
||||
|
||||
fn openai_responses_incomplete_finish_reason(payload: &Value) -> String {
|
||||
let reason = payload
|
||||
.get("response")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|response| response.get("incomplete_details"))
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| details.get("reason"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
match reason {
|
||||
"content_filter" => "content_filter",
|
||||
"tool_calls" | "function_call" => "tool_calls",
|
||||
_ => "length",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -110,26 +110,27 @@ pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<Canoni
|
||||
}
|
||||
|
||||
pub fn openai_stream_payload_is_terminal_error(payload: &Value) -> bool {
|
||||
let response = payload.get("response").and_then(Value::as_object);
|
||||
if payload.get("error").is_some()
|
||||
|| response
|
||||
.and_then(|response| response.get("error"))
|
||||
.is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let event_type = payload
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if payload.get("error").is_some() {
|
||||
return true;
|
||||
}
|
||||
if matches!(
|
||||
event_type,
|
||||
"error" | "response.failed" | "response.incomplete"
|
||||
) {
|
||||
if matches!(event_type, "error" | "response.failed") {
|
||||
return true;
|
||||
}
|
||||
|
||||
payload
|
||||
.get("response")
|
||||
.and_then(Value::as_object)
|
||||
response
|
||||
.and_then(|response| response.get("status"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|status| matches!(status, "failed" | "incomplete"))
|
||||
.is_some_and(|status| status == "failed")
|
||||
}
|
||||
|
||||
pub fn openai_stream_terminal_error_body(payload: &Value) -> Option<Value> {
|
||||
|
||||
@@ -983,6 +983,180 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_openai_responses_known_sidecar_events_without_unsupported_errors() {
|
||||
let report_context = report_context("openai:responses", "claude:messages");
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let mut output = Vec::new();
|
||||
|
||||
for line in [
|
||||
data_line(json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_sidecar_123",
|
||||
"model": "gpt-5.4",
|
||||
"status": "in_progress",
|
||||
"output": [],
|
||||
},
|
||||
})),
|
||||
data_line(json!({
|
||||
"type": "response.output_item.added",
|
||||
"response_id": "resp_sidecar_123",
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"type": "web_search_call",
|
||||
"id": "ws_123",
|
||||
"status": "in_progress",
|
||||
"action": {"type": "search", "query": "aether format conversion"},
|
||||
},
|
||||
})),
|
||||
data_line(json!({
|
||||
"type": "response.web_search_call.searching",
|
||||
"item_id": "ws_123",
|
||||
"output_index": 0,
|
||||
})),
|
||||
data_line(json!({
|
||||
"type": "response.output_text.annotation.added",
|
||||
"response_id": "resp_sidecar_123",
|
||||
"output_index": 1,
|
||||
"content_index": 0,
|
||||
"annotation_index": 0,
|
||||
"annotation": {"type": "url_citation", "url": "https://example.invalid"},
|
||||
})),
|
||||
data_line(json!({
|
||||
"type": "response.output_text.delta",
|
||||
"response_id": "resp_sidecar_123",
|
||||
"output_index": 1,
|
||||
"content_index": 0,
|
||||
"delta": "sidecar ok",
|
||||
})),
|
||||
data_line(json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_sidecar_123",
|
||||
"object": "response",
|
||||
"model": "gpt-5.4",
|
||||
"status": "completed",
|
||||
"output": [],
|
||||
"usage": {
|
||||
"input_tokens": 1,
|
||||
"output_tokens": 2,
|
||||
"total_tokens": 3,
|
||||
},
|
||||
},
|
||||
})),
|
||||
] {
|
||||
output.extend(
|
||||
matrix
|
||||
.transform_line(&report_context, line)
|
||||
.expect("known responses sidecar event should convert or be ignored"),
|
||||
);
|
||||
}
|
||||
|
||||
let sse = String::from_utf8(output).expect("sse should be utf8");
|
||||
assert!(!sse.contains("unsupported_stream_event"), "{sse}");
|
||||
assert!(!sse.contains("Unsupported provider stream event"), "{sse}");
|
||||
assert!(sse.contains("sidecar ok"), "{sse}");
|
||||
assert!(sse.contains("event: message_stop"), "{sse}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_openai_responses_incomplete_max_tokens_as_normal_finish() {
|
||||
let report_context = report_context("openai:responses", "claude:messages");
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let output = matrix
|
||||
.transform_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "response.incomplete",
|
||||
"response": {
|
||||
"id": "resp_incomplete_123",
|
||||
"object": "response",
|
||||
"model": "gpt-5.4",
|
||||
"status": "incomplete",
|
||||
"incomplete_details": {
|
||||
"reason": "max_output_tokens",
|
||||
},
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"id": "msg_incomplete_123",
|
||||
"role": "assistant",
|
||||
"status": "incomplete",
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": "partial answer",
|
||||
}],
|
||||
}],
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
},
|
||||
},
|
||||
})),
|
||||
)
|
||||
.expect("incomplete max token response should convert as length finish");
|
||||
|
||||
let sse = String::from_utf8(output).expect("sse should be utf8");
|
||||
assert!(!sse.contains("Response incomplete"), "{sse}");
|
||||
assert!(!sse.contains("unsupported_stream_event"), "{sse}");
|
||||
assert!(sse.contains("partial answer"), "{sse}");
|
||||
assert!(sse.contains("\"stop_reason\":\"max_tokens\""), "{sse}");
|
||||
assert!(matrix
|
||||
.finish(&report_context)
|
||||
.expect("finish should be terminated")
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_openai_responses_local_shell_call_to_claude_tool_use() {
|
||||
let report_context = report_context("openai:responses", "claude:messages");
|
||||
let mut matrix = StreamingStandardFormatMatrix::default();
|
||||
let mut output = Vec::new();
|
||||
|
||||
for line in [
|
||||
data_line(json!({
|
||||
"type": "response.output_item.done",
|
||||
"response_id": "resp_shell_123",
|
||||
"output_index": 0,
|
||||
"item": {
|
||||
"type": "local_shell_call",
|
||||
"id": "lsc_123",
|
||||
"call_id": "call_shell_123",
|
||||
"status": "completed",
|
||||
"action": {
|
||||
"type": "exec",
|
||||
"command": ["pwd"],
|
||||
"env": {},
|
||||
},
|
||||
},
|
||||
})),
|
||||
data_line(json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_shell_123",
|
||||
"object": "response",
|
||||
"model": "gpt-5.4",
|
||||
"status": "completed",
|
||||
"output": [],
|
||||
},
|
||||
})),
|
||||
] {
|
||||
output.extend(
|
||||
matrix
|
||||
.transform_line(&report_context, line)
|
||||
.expect("local shell call should convert to a generic tool use"),
|
||||
);
|
||||
}
|
||||
|
||||
let sse = String::from_utf8(output).expect("sse should be utf8");
|
||||
assert!(!sse.contains("unsupported_stream_event"), "{sse}");
|
||||
assert!(sse.contains("\"type\":\"tool_use\""), "{sse}");
|
||||
assert!(sse.contains("\"name\":\"local_shell\""), "{sse}");
|
||||
assert!(sse.contains("\\\"command\\\":[\\\"pwd\\\"]"), "{sse}");
|
||||
assert!(sse.contains("\"stop_reason\":\"tool_use\""), "{sse}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transforms_unknown_stream_finish_reasons_to_visible_client_errors() {
|
||||
let cases = [
|
||||
@@ -1452,6 +1626,45 @@ mod tests {
|
||||
assert_eq!(summary.unknown_event_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_observer_marks_openai_responses_incomplete_as_length_finish() {
|
||||
let mut report_context = report_context("openai:chat", "openai:responses");
|
||||
report_context["provider_stream_event_api_format"] = json!("openai:responses");
|
||||
let mut observer = StreamingStandardTerminalObserver::default();
|
||||
|
||||
observer
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "response.incomplete",
|
||||
"response": {
|
||||
"id": "resp_incomplete_123",
|
||||
"model": "gpt-5.4",
|
||||
"status": "incomplete",
|
||||
"incomplete_details": {
|
||||
"reason": "max_output_tokens",
|
||||
},
|
||||
"output": [],
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
},
|
||||
},
|
||||
})),
|
||||
)
|
||||
.expect("incomplete event should be observed as terminal finish");
|
||||
|
||||
let summary = observer
|
||||
.latest_summary()
|
||||
.cloned()
|
||||
.expect("summary should exist");
|
||||
assert!(summary.observed_finish);
|
||||
assert_eq!(summary.finish_reason.as_deref(), Some("length"));
|
||||
assert_eq!(summary.parser_error, None);
|
||||
assert_eq!(summary.unknown_event_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_observer_tracks_openai_image_stream_usage() {
|
||||
let mut report_context = report_context("openai:image", "openai:chat");
|
||||
|
||||
Reference in New Issue
Block a user