mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 04:09:48 +08:00
Merge remote-tracking branch 'origin/pr/536'
# Conflicts: # apps/aether-gateway/src/execution_runtime/stream/execution.rs
This commit is contained in:
@@ -1180,6 +1180,23 @@ impl OpenAIResponsesProviderState {
|
||||
}
|
||||
}
|
||||
}
|
||||
event_type if openai_stream_payload_is_terminal_error(&value) => {
|
||||
self.finished = true;
|
||||
let mut payload = value.clone();
|
||||
if event_type != "response.failed"
|
||||
&& event_type != "response.incomplete"
|
||||
&& event_type != "error"
|
||||
{
|
||||
payload = openai_stream_terminal_error_body(&value).unwrap_or(payload);
|
||||
if let Some(object) = payload.as_object_mut() {
|
||||
object.insert(
|
||||
"type".to_string(),
|
||||
Value::String("response.failed".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
out.push(self.unknown_frame(report_context, payload));
|
||||
}
|
||||
"response.completed" => {
|
||||
let Some(response) = value.get("response").and_then(Value::as_object) else {
|
||||
return Ok(out);
|
||||
@@ -1291,6 +1308,8 @@ pub struct OpenAIChatClientEmitter {
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
finished: bool,
|
||||
next_tool_call_index: usize,
|
||||
tool_call_index_by_canonical: BTreeMap<usize, usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
@@ -1375,6 +1394,17 @@ impl OpenAIChatClientEmitter {
|
||||
)
|
||||
}
|
||||
|
||||
fn chat_tool_call_index(&mut self, canonical_index: usize) -> usize {
|
||||
if let Some(index) = self.tool_call_index_by_canonical.get(&canonical_index) {
|
||||
return *index;
|
||||
}
|
||||
let index = self.next_tool_call_index;
|
||||
self.next_tool_call_index += 1;
|
||||
self.tool_call_index_by_canonical
|
||||
.insert(canonical_index, index);
|
||||
index
|
||||
}
|
||||
|
||||
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
self.update_identity(&frame);
|
||||
match frame.event {
|
||||
@@ -1483,6 +1513,7 @@ impl OpenAIChatClientEmitter {
|
||||
name,
|
||||
} => {
|
||||
let mut out = self.ensure_started()?;
|
||||
let chat_index = self.chat_tool_call_index(index);
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_chunk(
|
||||
@@ -1492,7 +1523,7 @@ impl OpenAIChatClientEmitter {
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
String::new(),
|
||||
Some(vec![json!({
|
||||
"index": index,
|
||||
"index": chat_index,
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -1507,6 +1538,7 @@ impl OpenAIChatClientEmitter {
|
||||
}
|
||||
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
|
||||
let mut out = self.ensure_started()?;
|
||||
let chat_index = self.chat_tool_call_index(index);
|
||||
out.extend(encode_json_sse(
|
||||
None,
|
||||
&json!({
|
||||
@@ -1519,7 +1551,7 @@ impl OpenAIChatClientEmitter {
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": index,
|
||||
"index": chat_index,
|
||||
"function": {
|
||||
"arguments": arguments,
|
||||
}
|
||||
@@ -1562,6 +1594,13 @@ impl OpenAIChatClientEmitter {
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::UnknownEvent(payload)
|
||||
if openai_stream_terminal_error_body(&payload).is_some() =>
|
||||
{
|
||||
self.finished = true;
|
||||
let error_body = openai_stream_terminal_error_body(&payload).unwrap_or(payload);
|
||||
encode_json_sse(None, &error_body)
|
||||
}
|
||||
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
|
||||
CanonicalStreamEvent::Finish {
|
||||
finish_reason,
|
||||
@@ -2508,6 +2547,29 @@ impl OpenAIResponsesClientEmitter {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
CanonicalStreamEvent::UnknownEvent(payload)
|
||||
if openai_stream_terminal_error_body(&payload).is_some() =>
|
||||
{
|
||||
self.finished = true;
|
||||
let raw_event = payload.get("type").and_then(Value::as_str);
|
||||
let event = raw_event
|
||||
.filter(|event| {
|
||||
matches!(*event, "response.failed" | "response.incomplete" | "error")
|
||||
})
|
||||
.unwrap_or("response.failed")
|
||||
.to_string();
|
||||
let mut payload = if raw_event == Some(event.as_str()) {
|
||||
payload
|
||||
} else {
|
||||
openai_stream_terminal_error_body(&payload).unwrap_or(payload)
|
||||
};
|
||||
if payload.get("type").is_none() {
|
||||
if let Some(object) = payload.as_object_mut() {
|
||||
object.insert("type".to_string(), Value::String(event.clone()));
|
||||
}
|
||||
}
|
||||
self.encode_response_event(event.as_str(), payload)
|
||||
}
|
||||
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
|
||||
CanonicalStreamEvent::Finish { usage, .. } => {
|
||||
if self.finished {
|
||||
@@ -2655,6 +2717,27 @@ mod tests {
|
||||
parts
|
||||
}
|
||||
|
||||
fn openai_chat_tool_call_indices(sse: &str) -> Vec<u64> {
|
||||
let mut indices = 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(tool_calls) = value
|
||||
.pointer("/choices/0/delta/tool_calls")
|
||||
.and_then(Value::as_array)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for tool_call in tool_calls {
|
||||
if let Some(index) = tool_call.get("index").and_then(Value::as_u64) {
|
||||
indices.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
indices
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_provider_state_emits_unknown_events_for_unrecognized_deltas() {
|
||||
let mut state = OpenAIChatProviderState::default();
|
||||
@@ -2713,6 +2796,40 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_provider_state_treats_failed_event_as_terminal() {
|
||||
let mut state = OpenAIResponsesProviderState::default();
|
||||
let report_context = json!({});
|
||||
let frames = state
|
||||
.push_line(
|
||||
&report_context,
|
||||
data_line(json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "resp_failed_123",
|
||||
"model": "gpt-5.4",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": "policy failure",
|
||||
"type": "invalid_request_error",
|
||||
"code": "cyber_policy"
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("failed 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.failed")
|
||||
)));
|
||||
assert!(state
|
||||
.finish(&report_context)
|
||||
.expect("terminal failure should not synthesize completion")
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_usage_derives_missing_input_tokens_from_total() {
|
||||
let usage = canonical_usage_from_openai_usage(Some(&json!({
|
||||
@@ -2879,6 +2996,41 @@ mod tests {
|
||||
assert_eq!(response_sequence_numbers(&sse), (1..=9).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_client_emitter_forwards_failed_unknown_event() {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
let bytes = emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "resp_failed_123".to_string(),
|
||||
model: "gpt-5.4".to_string(),
|
||||
event: CanonicalStreamEvent::UnknownEvent(json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "resp_failed_123",
|
||||
"model": "gpt-5.4",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"message": "policy failure",
|
||||
"type": "invalid_request_error",
|
||||
"code": "cyber_policy"
|
||||
}
|
||||
}
|
||||
})),
|
||||
})
|
||||
.expect("failed response event should encode");
|
||||
let mut all = bytes;
|
||||
all.extend(
|
||||
emitter
|
||||
.finish()
|
||||
.expect("failed stream should not synthesize completion"),
|
||||
);
|
||||
|
||||
let sse = String::from_utf8(all).expect("sse should be utf8");
|
||||
assert!(sse.contains("event: response.failed\n"));
|
||||
assert!(sse.contains("\"message\":\"policy failure\""));
|
||||
assert!(!sse.contains("event: response.completed\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_client_emitter_keeps_text_item_id_stable_after_text_started() {
|
||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||
@@ -3367,6 +3519,50 @@ mod tests {
|
||||
assert!(sse.contains("[Image]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_client_emitter_normalizes_sparse_tool_call_indices() {
|
||||
let mut emitter = OpenAIChatClientEmitter::default();
|
||||
let mut bytes = Vec::new();
|
||||
|
||||
for event in [
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index: 1,
|
||||
call_id: "call_first".to_string(),
|
||||
name: "first_tool".to_string(),
|
||||
},
|
||||
CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index: 1,
|
||||
arguments: "{\"first\":".to_string(),
|
||||
},
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index: 3,
|
||||
call_id: "call_second".to_string(),
|
||||
name: "second_tool".to_string(),
|
||||
},
|
||||
CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index: 3,
|
||||
arguments: "{\"second\":true}".to_string(),
|
||||
},
|
||||
CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index: 1,
|
||||
arguments: "true}".to_string(),
|
||||
},
|
||||
] {
|
||||
bytes.extend(
|
||||
emitter
|
||||
.emit(CanonicalStreamFrame {
|
||||
id: "chatcmpl_sparse".to_string(),
|
||||
model: "claude-opus-4-6".to_string(),
|
||||
event,
|
||||
})
|
||||
.expect("tool event should encode"),
|
||||
);
|
||||
}
|
||||
|
||||
let sse = String::from_utf8(bytes).expect("sse should be utf8");
|
||||
assert_eq!(openai_chat_tool_call_indices(&sse), vec![0, 0, 1, 1, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_client_emitter_emits_usage_only_final_chunk() {
|
||||
let mut emitter = OpenAIChatClientEmitter::default();
|
||||
|
||||
Reference in New Issue
Block a user