mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
fix: harden concurrency limits and high-RPM runtime paths
Bound request, stream, queue, and shutdown resource lifetimes. Reduce scheduler and Redis hot-path work and isolate database maintenance. Include regression coverage, load probes, and concurrency audit results.
This commit is contained in:
@@ -24,6 +24,8 @@ struct GeminiProviderToolResultState {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GeminiProviderState {
|
||||
terminal_observation_only: bool,
|
||||
observed_tool_calls: bool,
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
started: bool,
|
||||
@@ -37,6 +39,13 @@ pub struct GeminiProviderState {
|
||||
}
|
||||
|
||||
impl GeminiProviderState {
|
||||
pub(crate) fn terminal_observation() -> Self {
|
||||
Self {
|
||||
terminal_observation_only: true,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn identity(&self, report_context: &Value) -> (String, String) {
|
||||
resolve_identity(
|
||||
self.response_id.as_deref(),
|
||||
@@ -133,14 +142,21 @@ impl GeminiProviderState {
|
||||
let Some(part_object) = part.as_object() else {
|
||||
continue;
|
||||
};
|
||||
let reasoning_signature = part_object
|
||||
.get("thoughtSignature")
|
||||
.or_else(|| part_object.get("thought_signature"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let reasoning_signature = if self.terminal_observation_only {
|
||||
None
|
||||
} else {
|
||||
part_object
|
||||
.get("thoughtSignature")
|
||||
.or_else(|| part_object.get("thought_signature"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
};
|
||||
if let Some(text) = render_gemini_part_as_text(part_object) {
|
||||
if self.terminal_observation_only {
|
||||
continue;
|
||||
}
|
||||
let is_reasoning = part_object
|
||||
.get("thought")
|
||||
.and_then(Value::as_bool)
|
||||
@@ -193,6 +209,9 @@ impl GeminiProviderState {
|
||||
.or_else(|| part_object.get("function_response"))
|
||||
.and_then(Value::as_object)
|
||||
{
|
||||
if self.terminal_observation_only {
|
||||
continue;
|
||||
}
|
||||
let tool_use_id = function_response
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
@@ -240,6 +259,9 @@ impl GeminiProviderState {
|
||||
else {
|
||||
if let Some(content_part) = canonical_content_part_from_gemini_part(part_object)
|
||||
{
|
||||
if self.terminal_observation_only {
|
||||
continue;
|
||||
}
|
||||
let should_emit = self
|
||||
.content_parts
|
||||
.get(&index)
|
||||
@@ -260,6 +282,10 @@ impl GeminiProviderState {
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if self.terminal_observation_only {
|
||||
self.observed_tool_calls = true;
|
||||
continue;
|
||||
}
|
||||
let tool_state = self.tool_calls.entry(index).or_default();
|
||||
tool_state.call_id = function_call
|
||||
.get("id")
|
||||
@@ -329,7 +355,7 @@ impl GeminiProviderState {
|
||||
if let Some(finish_reason) =
|
||||
candidate_object.get("finishReason").and_then(Value::as_str)
|
||||
{
|
||||
let has_tool_calls = !self.tool_calls.is_empty();
|
||||
let has_tool_calls = self.observed_tool_calls || !self.tool_calls.is_empty();
|
||||
let mut finish_reason =
|
||||
normalize_openai_finish_reason(map_gemini_stream_finish_reason(finish_reason));
|
||||
if has_tool_calls && finish_reason.as_deref().is_none_or(|value| value == "stop") {
|
||||
@@ -936,6 +962,232 @@ mod tests {
|
||||
format!("data: {}\n", value).into_bytes()
|
||||
}
|
||||
|
||||
fn terminal_frames(frames: Vec<CanonicalStreamFrame>) -> Vec<CanonicalStreamFrame> {
|
||||
frames
|
||||
.into_iter()
|
||||
.filter(|frame| {
|
||||
matches!(
|
||||
frame.event,
|
||||
CanonicalStreamEvent::Start
|
||||
| CanonicalStreamEvent::Finish { .. }
|
||||
| CanonicalStreamEvent::UnknownEvent(_)
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn observation_record(parts: Vec<Value>, finish_reason: Option<&str>) -> Value {
|
||||
let mut record = json!({
|
||||
"responseId": "resp_observation",
|
||||
"modelVersion": "gemini-2.5-pro",
|
||||
"candidates": [{"content": {"parts": parts}}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 22,
|
||||
"cachedContentTokenCount": 7,
|
||||
"candidatesTokenCount": 13,
|
||||
"thoughtsTokenCount": 5,
|
||||
"totalTokenCount": 40
|
||||
}
|
||||
});
|
||||
if let Some(finish_reason) = finish_reason {
|
||||
record["candidates"][0]["finishReason"] = json!(finish_reason);
|
||||
}
|
||||
record
|
||||
}
|
||||
|
||||
fn assert_terminal_record_matches(
|
||||
normal: &mut GeminiProviderState,
|
||||
observer: &mut GeminiProviderState,
|
||||
record: Value,
|
||||
) -> Vec<CanonicalStreamFrame> {
|
||||
let context = json!({"mapped_model": "fallback-model"});
|
||||
let expected = terminal_frames(
|
||||
normal
|
||||
.push_line(&context, data_line(record.clone()))
|
||||
.expect("normal provider parser"),
|
||||
);
|
||||
let actual = observer
|
||||
.push_line(&context, data_line(record))
|
||||
.expect("terminal provider parser");
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"terminal frames must retain their full payloads"
|
||||
);
|
||||
actual
|
||||
}
|
||||
|
||||
fn assert_observer_has_no_content_buffers(observer: &GeminiProviderState) {
|
||||
assert!(observer.text_parts.is_empty());
|
||||
assert!(observer.reasoning_parts.is_empty());
|
||||
assert!(observer.reasoning_signatures.is_empty());
|
||||
assert!(observer.content_parts.is_empty());
|
||||
assert!(observer.tool_calls.is_empty());
|
||||
assert!(observer.tool_results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_terminal_observation_does_not_retain_long_stream_content() {
|
||||
let mut normal = GeminiProviderState::default();
|
||||
let mut observer = GeminiProviderState::terminal_observation();
|
||||
let media = "YQ==".repeat(4_096);
|
||||
for index in 0..32 {
|
||||
let text = format!("record-{index}:{}", "x".repeat(16_384));
|
||||
assert_terminal_record_matches(
|
||||
&mut normal,
|
||||
&mut observer,
|
||||
observation_record(
|
||||
vec![
|
||||
json!({"text": text}),
|
||||
json!({"text": text, "thought": true, "thoughtSignature": media}),
|
||||
json!({"functionCall": {"id": format!("call-{index}"), "name": "lookup", "args": {"value": text}}}),
|
||||
json!({"functionResponse": {"name": "lookup", "response": {"result": text}}}),
|
||||
json!({"inlineData": {"mimeType": "image/png", "data": media}}),
|
||||
json!({"inline_data": {"mime_type": "audio/wav", "data": media}}),
|
||||
json!({"inlineData": {"mimeType": "application/pdf", "data": media}}),
|
||||
],
|
||||
None,
|
||||
),
|
||||
);
|
||||
assert_observer_has_no_content_buffers(&observer);
|
||||
assert!(observer.observed_tool_calls);
|
||||
}
|
||||
assert!(!normal.text_parts.is_empty());
|
||||
assert!(!normal.reasoning_parts.is_empty());
|
||||
assert!(!normal.reasoning_signatures.is_empty());
|
||||
assert!(!normal.content_parts.is_empty());
|
||||
assert!(!normal.tool_calls.is_empty());
|
||||
assert!(normal
|
||||
.tool_results
|
||||
.values()
|
||||
.any(|state| state.content.len() > 16_384));
|
||||
let frames = assert_terminal_record_matches(
|
||||
&mut normal,
|
||||
&mut observer,
|
||||
observation_record(Vec::new(), Some("STOP")),
|
||||
);
|
||||
assert!(frames.iter().any(|frame| matches!(
|
||||
&frame.event,
|
||||
CanonicalStreamEvent::Finish { finish_reason: Some(reason), usage: Some(usage) }
|
||||
if reason == "tool_calls" && usage.input_tokens == 22 && usage.output_tokens == 18
|
||||
&& usage.cache_read_tokens == 7 && usage.reasoning_tokens == 5 && usage.total_tokens == 40
|
||||
)));
|
||||
assert_observer_has_no_content_buffers(&observer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_terminal_observation_matches_known_and_unknown_part_classification() {
|
||||
let parts = vec![
|
||||
Value::Null,
|
||||
json!({}),
|
||||
json!({"text": ""}),
|
||||
json!({"text": 17}),
|
||||
json!({"text": "", "thought_signature": "signature"}),
|
||||
json!({"thoughtSignature": "signature"}),
|
||||
json!({"executableCode": {"language": "python", "code": "print(1)"}}),
|
||||
json!({"codeExecutionResult": {"output": "1"}}),
|
||||
json!({"functionResponse": {}}),
|
||||
json!({"function_response": {"response": ["result"]}}),
|
||||
json!({"functionCall": {}}),
|
||||
json!({"functionCall": null}),
|
||||
json!({"inlineData": {"mimeType": "image/png", "data": "YQ=="}}),
|
||||
json!({"inline_data": {"mime_type": "audio/wav", "data": "YQ=="}}),
|
||||
json!({"inlineData": {"mimeType": "application/pdf", "data": "YQ=="}}),
|
||||
json!({"inlineData": {"mimeType": "", "data": "YQ=="}}),
|
||||
json!({"inlineData": {"mimeType": "image/png", "data": ""}}),
|
||||
json!({"file_data": {"file_uri": "gs://test/file", "mime_type": "application/pdf"}}),
|
||||
json!({"fileData": {"fileUri": ""}}),
|
||||
json!({"futurePart": {"kept": true}}),
|
||||
];
|
||||
for part in parts {
|
||||
let mut normal = GeminiProviderState::default();
|
||||
let mut observer = GeminiProviderState::terminal_observation();
|
||||
assert_terminal_record_matches(
|
||||
&mut normal,
|
||||
&mut observer,
|
||||
json!({"responseId": "outer", "response": observation_record(vec![part], Some("STOP"))}),
|
||||
);
|
||||
assert_eq!(observer.response_id.as_deref(), Some("resp_observation"));
|
||||
assert_observer_has_no_content_buffers(&observer);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_terminal_observation_preserves_finish_reasons_and_eof() {
|
||||
for has_tool in [false, true] {
|
||||
for reason in [
|
||||
None,
|
||||
Some("STOP"),
|
||||
Some("MAX_TOKENS"),
|
||||
Some("RECITATION"),
|
||||
Some("FUTURE_REASON"),
|
||||
] {
|
||||
let mut normal = GeminiProviderState::default();
|
||||
let mut observer = GeminiProviderState::terminal_observation();
|
||||
let part = if has_tool {
|
||||
json!({"functionCall": {"name": "lookup", "args": {"query": "test"}}})
|
||||
} else {
|
||||
json!({"text": "partial output"})
|
||||
};
|
||||
assert_terminal_record_matches(
|
||||
&mut normal,
|
||||
&mut observer,
|
||||
observation_record(vec![part], None),
|
||||
);
|
||||
if let Some(reason) = reason {
|
||||
assert_terminal_record_matches(
|
||||
&mut normal,
|
||||
&mut observer,
|
||||
observation_record(Vec::new(), Some(reason)),
|
||||
);
|
||||
}
|
||||
let context = json!({});
|
||||
assert_eq!(
|
||||
observer.finish(&context).expect("observer EOF"),
|
||||
terminal_frames(normal.finish(&context).expect("normal EOF"))
|
||||
);
|
||||
assert!(observer
|
||||
.finish(&context)
|
||||
.expect("idempotent EOF")
|
||||
.is_empty());
|
||||
assert_observer_has_no_content_buffers(&observer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_terminal_observation_preserves_failure_payloads_with_usage() {
|
||||
for reason in [
|
||||
"MALFORMED_FUNCTION_CALL",
|
||||
"UNEXPECTED_TOOL_CALL",
|
||||
"TOO_MANY_TOOL_CALLS",
|
||||
"MISSING_THOUGHT_SIGNATURE",
|
||||
"MALFORMED_RESPONSE",
|
||||
] {
|
||||
for content in [
|
||||
Value::Null,
|
||||
json!({}),
|
||||
json!({"parts": []}),
|
||||
json!({"parts": [{"text": ""}]}),
|
||||
] {
|
||||
let mut normal = GeminiProviderState::default();
|
||||
let mut observer = GeminiProviderState::terminal_observation();
|
||||
let mut record = observation_record(Vec::new(), Some(reason));
|
||||
record["candidates"][0]["content"] = content;
|
||||
record["candidates"][0]["finishMessage"] = json!("provider failure details");
|
||||
let frames = assert_terminal_record_matches(&mut normal, &mut observer, record);
|
||||
assert!(frames.iter().any(|frame| matches!(
|
||||
&frame.event,
|
||||
CanonicalStreamEvent::UnknownEvent(payload)
|
||||
if payload["response"]["error"]["code"] == reason
|
||||
&& payload["response"]["error"]["message"] == "provider failure details"
|
||||
&& payload["response"]["usage"]["input_tokens"] == 22
|
||||
)));
|
||||
assert!(observer.finish(&json!({})).expect("failed EOF").is_empty());
|
||||
assert_observer_has_no_content_buffers(&observer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_provider_state_emits_unknown_events_for_unknown_parts() {
|
||||
let mut state = GeminiProviderState::default();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::formats::openai::namespace::NamespaceToolAliases;
|
||||
use crate::formats::openai::responses::{
|
||||
@@ -33,6 +34,7 @@ struct OpenAIChatProviderToolState {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAIChatProviderState {
|
||||
terminal_only: bool,
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
actual_service_tier: Option<String>,
|
||||
@@ -59,6 +61,7 @@ struct OpenAIResponsesProviderToolResultState {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct OpenAIResponsesProviderState {
|
||||
terminal_only: bool,
|
||||
response_id: Option<String>,
|
||||
model: Option<String>,
|
||||
actual_service_tier: Option<String>,
|
||||
@@ -71,11 +74,24 @@ pub struct OpenAIResponsesProviderState {
|
||||
tool_results: BTreeMap<usize, OpenAIResponsesProviderToolResultState>,
|
||||
tool_index_by_key: BTreeMap<String, usize>,
|
||||
image_item_keys: BTreeSet<String>,
|
||||
opaque_completed_item_keys: BTreeSet<String>,
|
||||
opaque_completed_item_keys: BTreeSet<OpenAIResponsesOutputItemKey>,
|
||||
last_tool_index: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum OpenAIResponsesOutputItemKey {
|
||||
Full(String),
|
||||
Digest([u8; 32]),
|
||||
}
|
||||
|
||||
impl OpenAIChatProviderState {
|
||||
pub(crate) fn terminal_observation() -> Self {
|
||||
Self {
|
||||
terminal_only: true,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn actual_service_tier(&self) -> Option<&str> {
|
||||
self.actual_service_tier.as_deref()
|
||||
}
|
||||
@@ -243,12 +259,14 @@ impl OpenAIChatProviderState {
|
||||
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()),
|
||||
});
|
||||
if !self.terminal_only {
|
||||
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;
|
||||
@@ -258,12 +276,16 @@ impl OpenAIChatProviderState {
|
||||
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()),
|
||||
});
|
||||
if !self.terminal_only {
|
||||
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;
|
||||
@@ -272,68 +294,71 @@ impl OpenAIChatProviderState {
|
||||
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());
|
||||
}
|
||||
let mut arguments = None;
|
||||
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 !self.terminal_only {
|
||||
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());
|
||||
}
|
||||
arguments = function
|
||||
.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|arguments| !arguments.is_empty());
|
||||
}
|
||||
if !state.started_emitted {
|
||||
if let Some(arguments) = arguments {
|
||||
state.pending_arguments.push_str(arguments);
|
||||
}
|
||||
if let (Some(call_id), Some(name)) = (state.id.clone(), state.name.clone())
|
||||
let mut arguments = None;
|
||||
if let Some(function) =
|
||||
tool_call_object.get("function").and_then(Value::as_object)
|
||||
{
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
name,
|
||||
},
|
||||
});
|
||||
state.started_emitted = true;
|
||||
if !state.pending_arguments.is_empty() {
|
||||
if let Some(name) = function.get("name").and_then(Value::as_str) {
|
||||
state.name = Some(name.to_string());
|
||||
}
|
||||
arguments = function
|
||||
.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|arguments| !arguments.is_empty());
|
||||
}
|
||||
if !state.started_emitted {
|
||||
if let Some(arguments) = arguments {
|
||||
state.pending_arguments.push_str(arguments);
|
||||
}
|
||||
if let (Some(call_id), Some(name)) =
|
||||
(state.id.clone(), state.name.clone())
|
||||
{
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
event: CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
arguments: std::mem::take(&mut state.pending_arguments),
|
||||
call_id,
|
||||
name,
|
||||
},
|
||||
});
|
||||
state.started_emitted = true;
|
||||
if !state.pending_arguments.is_empty() {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: std::mem::take(&mut state.pending_arguments),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if let Some(arguments) = arguments {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: arguments.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if let Some(arguments) = arguments {
|
||||
out.push(CanonicalStreamFrame {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: arguments.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if delta.contains_key("tool_calls") {
|
||||
@@ -389,6 +414,13 @@ impl OpenAIChatProviderState {
|
||||
}
|
||||
|
||||
impl OpenAIResponsesProviderState {
|
||||
pub(crate) fn terminal_observation() -> Self {
|
||||
Self {
|
||||
terminal_only: true,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn actual_service_tier(&self) -> Option<&str> {
|
||||
self.actual_service_tier.as_deref()
|
||||
}
|
||||
@@ -494,6 +526,10 @@ impl OpenAIResponsesProviderState {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.terminal_only {
|
||||
self.ensure_started(report_context, out);
|
||||
return;
|
||||
}
|
||||
self.text_parts.entry(key).or_default().push_str(text);
|
||||
self.ensure_started(report_context, out);
|
||||
let (id, model) = self.identity(report_context);
|
||||
@@ -511,6 +547,12 @@ impl OpenAIResponsesProviderState {
|
||||
key: String,
|
||||
text: &str,
|
||||
) {
|
||||
if self.terminal_only {
|
||||
if !text.is_empty() {
|
||||
self.ensure_started(report_context, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let missing = {
|
||||
let current = self.text_parts.entry(key).or_default();
|
||||
let missing = if text.starts_with(current.as_str()) {
|
||||
@@ -543,6 +585,12 @@ impl OpenAIResponsesProviderState {
|
||||
out: &mut Vec<CanonicalStreamFrame>,
|
||||
reasoning: &str,
|
||||
) {
|
||||
if self.terminal_only {
|
||||
if !reasoning.is_empty() {
|
||||
self.ensure_started(report_context, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let missing = if reasoning.starts_with(&self.reasoning) {
|
||||
reasoning[self.reasoning.len()..].to_string()
|
||||
} else if self.reasoning == reasoning {
|
||||
@@ -573,6 +621,10 @@ impl OpenAIResponsesProviderState {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.terminal_only {
|
||||
self.ensure_started(report_context, out);
|
||||
return;
|
||||
}
|
||||
let missing = {
|
||||
let current = self.reasoning_parts.entry(summary_index).or_default();
|
||||
let missing = if text.starts_with(current.as_str()) {
|
||||
@@ -608,6 +660,9 @@ impl OpenAIResponsesProviderState {
|
||||
out: &mut Vec<CanonicalStreamFrame>,
|
||||
index: usize,
|
||||
) {
|
||||
if self.terminal_only {
|
||||
return;
|
||||
}
|
||||
let (id, model) = self.identity(report_context);
|
||||
let Some(state) = self.tool_calls.get_mut(&index) else {
|
||||
return;
|
||||
@@ -806,12 +861,13 @@ impl OpenAIResponsesProviderState {
|
||||
if let Some(name) = incoming_chat_name {
|
||||
state.name = name;
|
||||
}
|
||||
let completed_arguments = item
|
||||
.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
Self::merge_tool_call_arguments(state, &completed_arguments);
|
||||
if !self.terminal_only {
|
||||
let completed_arguments = item
|
||||
.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
Self::merge_tool_call_arguments(state, completed_arguments);
|
||||
}
|
||||
self.emit_ready_function_call(report_context, out, index);
|
||||
}
|
||||
|
||||
@@ -832,10 +888,14 @@ impl OpenAIResponsesProviderState {
|
||||
.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",
|
||||
);
|
||||
let arguments = if self.terminal_only {
|
||||
String::new()
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -852,16 +912,20 @@ impl OpenAIResponsesProviderState {
|
||||
"shell_call" => "shell",
|
||||
_ => return,
|
||||
};
|
||||
let arguments = tool_arguments_from_named_fields(
|
||||
item,
|
||||
&[
|
||||
"action",
|
||||
"environment",
|
||||
"status",
|
||||
"created_by",
|
||||
"max_output_length",
|
||||
],
|
||||
);
|
||||
let arguments = if self.terminal_only {
|
||||
String::new()
|
||||
} else {
|
||||
tool_arguments_from_named_fields(
|
||||
item,
|
||||
&[
|
||||
"action",
|
||||
"environment",
|
||||
"status",
|
||||
"created_by",
|
||||
"max_output_length",
|
||||
],
|
||||
)
|
||||
};
|
||||
self.emit_generic_tool_call_item(
|
||||
report_context,
|
||||
out,
|
||||
@@ -882,7 +946,11 @@ impl OpenAIResponsesProviderState {
|
||||
if item.get("type").and_then(Value::as_str) != Some("apply_patch_call") {
|
||||
return;
|
||||
}
|
||||
let arguments = tool_arguments_from_named_fields(item, &["operation", "status"]);
|
||||
let arguments = if self.terminal_only {
|
||||
String::new()
|
||||
} else {
|
||||
tool_arguments_from_named_fields(item, &["operation", "status"])
|
||||
};
|
||||
self.emit_generic_tool_call_item(
|
||||
report_context,
|
||||
out,
|
||||
@@ -903,10 +971,14 @@ impl OpenAIResponsesProviderState {
|
||||
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"],
|
||||
);
|
||||
let arguments = if self.terminal_only {
|
||||
String::new()
|
||||
} else {
|
||||
tool_arguments_from_named_fields(
|
||||
item,
|
||||
&["action", "actions", "pending_safety_checks", "status"],
|
||||
)
|
||||
};
|
||||
self.emit_generic_tool_call_item(
|
||||
report_context,
|
||||
out,
|
||||
@@ -941,10 +1013,20 @@ impl OpenAIResponsesProviderState {
|
||||
.unwrap_or(state.call_id.as_str())
|
||||
.to_string();
|
||||
state.name = name;
|
||||
Self::merge_tool_call_arguments(state, &arguments);
|
||||
if !self.terminal_only {
|
||||
Self::merge_tool_call_arguments(state, &arguments);
|
||||
}
|
||||
self.emit_ready_tool_call(report_context, out, index);
|
||||
}
|
||||
|
||||
fn tool_result_content(&self, value: Option<&Value>) -> String {
|
||||
if self.terminal_only {
|
||||
String::new()
|
||||
} else {
|
||||
openai_tool_result_content_from_value(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_missing_tool_result(
|
||||
&mut self,
|
||||
report_context: &Value,
|
||||
@@ -955,6 +1037,9 @@ impl OpenAIResponsesProviderState {
|
||||
content: &str,
|
||||
) {
|
||||
self.ensure_started(report_context, out);
|
||||
if self.terminal_only {
|
||||
return;
|
||||
}
|
||||
let state = self.tool_results.entry(index).or_default();
|
||||
let missing = if !state.emitted {
|
||||
content.to_string()
|
||||
@@ -1005,7 +1090,7 @@ impl OpenAIResponsesProviderState {
|
||||
Some(format!("function_call_output:{tool_use_id}")),
|
||||
output_index,
|
||||
);
|
||||
let content = openai_tool_result_content_from_value(
|
||||
let content = self.tool_result_content(
|
||||
item.get("output")
|
||||
.or_else(|| item.get("content"))
|
||||
.or_else(|| item.get("delta")),
|
||||
@@ -1047,7 +1132,7 @@ impl OpenAIResponsesProviderState {
|
||||
.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(
|
||||
let content = self.tool_result_content(
|
||||
item.get("output")
|
||||
.or_else(|| item.get("content"))
|
||||
.or_else(|| item.get("delta")),
|
||||
@@ -1110,6 +1195,24 @@ impl OpenAIResponsesProviderState {
|
||||
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
|
||||
return;
|
||||
}
|
||||
if self.terminal_only {
|
||||
if item
|
||||
.get("summary")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|summary| {
|
||||
summary.iter().any(|part| {
|
||||
part.get("type").and_then(Value::as_str) == Some("summary_text")
|
||||
&& part
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|text| !text.is_empty())
|
||||
})
|
||||
})
|
||||
{
|
||||
self.ensure_started(report_context, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut completed_reasoning = String::new();
|
||||
for raw_summary in item
|
||||
.get("summary")
|
||||
@@ -1159,6 +1262,10 @@ impl OpenAIResponsesProviderState {
|
||||
if !has_image_payload {
|
||||
return;
|
||||
}
|
||||
if self.terminal_only {
|
||||
self.ensure_started(report_context, out);
|
||||
return;
|
||||
}
|
||||
let index = output_index.unwrap_or(self.image_item_keys.len());
|
||||
let key = item
|
||||
.get("id")
|
||||
@@ -1180,6 +1287,42 @@ impl OpenAIResponsesProviderState {
|
||||
});
|
||||
}
|
||||
|
||||
fn retained_output_item_key(&self, item: &Map<String, Value>) -> OpenAIResponsesOutputItemKey {
|
||||
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
|
||||
if self.terminal_only {
|
||||
struct DigestWriter(Sha256);
|
||||
|
||||
impl std::io::Write for DigestWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.update(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Hash exactly the normal key's bytes, without retaining or first
|
||||
// serializing an entire encrypted/opaque output item into a string.
|
||||
let mut writer = DigestWriter(Sha256::new());
|
||||
writer.0.update(item_type.as_bytes());
|
||||
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
|
||||
writer.0.update(b":id:");
|
||||
writer.0.update(item_id.as_bytes());
|
||||
} else if let Some(content) = item.get("encrypted_content").and_then(Value::as_str) {
|
||||
writer.0.update(b":encrypted_content:");
|
||||
writer.0.update(content.as_bytes());
|
||||
} else {
|
||||
writer.0.update(b":");
|
||||
serde_json::to_writer(&mut writer, item)
|
||||
.expect("JSON value serialization into a digest cannot fail");
|
||||
}
|
||||
return OpenAIResponsesOutputItemKey::Digest(writer.0.finalize().into());
|
||||
}
|
||||
OpenAIResponsesOutputItemKey::Full(Self::output_item_key(item))
|
||||
}
|
||||
|
||||
fn output_item_key(item: &Map<String, Value>) -> String {
|
||||
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
|
||||
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
|
||||
@@ -1290,10 +1433,13 @@ impl OpenAIResponsesProviderState {
|
||||
}
|
||||
|
||||
if final_item {
|
||||
self.opaque_completed_item_keys
|
||||
.insert(Self::output_item_key(item));
|
||||
let key = self.retained_output_item_key(item);
|
||||
self.opaque_completed_item_keys.insert(key);
|
||||
}
|
||||
self.ensure_started(report_context, out);
|
||||
if self.terminal_only {
|
||||
return;
|
||||
}
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
@@ -1325,7 +1471,7 @@ impl OpenAIResponsesProviderState {
|
||||
if !self.emit_output_item(report_context, out, item, Some(output_index), true)
|
||||
&& !self
|
||||
.opaque_completed_item_keys
|
||||
.contains(&Self::output_item_key(item))
|
||||
.contains(&self.retained_output_item_key(item))
|
||||
{
|
||||
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
|
||||
}
|
||||
@@ -1504,6 +1650,9 @@ impl OpenAIResponsesProviderState {
|
||||
.map(|value| value as usize)
|
||||
.unwrap_or(0);
|
||||
self.ensure_started(report_context, &mut out);
|
||||
if self.terminal_only {
|
||||
return Ok(out);
|
||||
}
|
||||
self.reasoning.push_str(piece);
|
||||
self.reasoning_parts
|
||||
.entry(summary_index)
|
||||
@@ -1543,6 +1692,9 @@ impl OpenAIResponsesProviderState {
|
||||
);
|
||||
}
|
||||
self.ensure_started(report_context, &mut out);
|
||||
if self.terminal_only {
|
||||
return Ok(out);
|
||||
}
|
||||
let (id, model) = self.identity(report_context);
|
||||
out.push(CanonicalStreamFrame {
|
||||
id,
|
||||
@@ -1595,7 +1747,9 @@ impl OpenAIResponsesProviderState {
|
||||
.unwrap_or("custom_tool")
|
||||
.to_string();
|
||||
}
|
||||
state.arguments.push_str(delta);
|
||||
if !self.terminal_only {
|
||||
state.arguments.push_str(delta);
|
||||
}
|
||||
self.emit_ready_tool_call(report_context, &mut out, index);
|
||||
}
|
||||
"response.custom_tool_call_input.done" => {
|
||||
@@ -1623,11 +1777,13 @@ impl OpenAIResponsesProviderState {
|
||||
.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);
|
||||
if !self.terminal_only {
|
||||
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" => {
|
||||
@@ -1654,7 +1810,9 @@ impl OpenAIResponsesProviderState {
|
||||
if let Some(call_id) = value.get("call_id").and_then(Value::as_str) {
|
||||
state.call_id = call_id.to_string();
|
||||
}
|
||||
state.arguments.push_str(delta);
|
||||
if !self.terminal_only {
|
||||
state.arguments.push_str(delta);
|
||||
}
|
||||
self.emit_ready_function_call(report_context, &mut out, index);
|
||||
}
|
||||
"response.function_call_arguments.done" => {
|
||||
@@ -1722,7 +1880,9 @@ impl OpenAIResponsesProviderState {
|
||||
if let Some(name) = incoming_chat_name {
|
||||
state.name = name;
|
||||
}
|
||||
Self::merge_tool_call_arguments(state, arguments);
|
||||
if !self.terminal_only {
|
||||
Self::merge_tool_call_arguments(state, arguments);
|
||||
}
|
||||
self.emit_ready_function_call(report_context, &mut out, index);
|
||||
}
|
||||
"response.function_call_output.delta" | "response.function_call_output.done" => {
|
||||
@@ -1743,7 +1903,7 @@ impl OpenAIResponsesProviderState {
|
||||
Some(format!("function_call_output:{tool_use_id}")),
|
||||
output_index,
|
||||
);
|
||||
let content = openai_tool_result_content_from_value(
|
||||
let content = self.tool_result_content(
|
||||
value
|
||||
.get("delta")
|
||||
.or_else(|| value.get("output"))
|
||||
@@ -1781,7 +1941,7 @@ impl OpenAIResponsesProviderState {
|
||||
.map(|value| value as usize);
|
||||
let index = self
|
||||
.tool_index_for_key(Some(format!("{item_type}:{tool_use_id}")), output_index);
|
||||
let content = openai_tool_result_content_from_value(
|
||||
let content = self.tool_result_content(
|
||||
value
|
||||
.get("delta")
|
||||
.or_else(|| value.get("output"))
|
||||
@@ -3754,6 +3914,276 @@ mod tests {
|
||||
format!("data: {}\n", value).into_bytes()
|
||||
}
|
||||
|
||||
fn terminal_frames(frames: Vec<CanonicalStreamFrame>) -> Vec<CanonicalStreamFrame> {
|
||||
frames
|
||||
.into_iter()
|
||||
.filter(|frame| {
|
||||
matches!(
|
||||
frame.event,
|
||||
CanonicalStreamEvent::Start
|
||||
| CanonicalStreamEvent::UnknownEvent(_)
|
||||
| CanonicalStreamEvent::Finish { .. }
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_no_responses_observation_content(state: &OpenAIResponsesProviderState) {
|
||||
assert!(state.text_parts.is_empty());
|
||||
assert_eq!(state.reasoning.capacity(), 0);
|
||||
assert!(state.reasoning_parts.is_empty());
|
||||
assert!(state
|
||||
.tool_calls
|
||||
.values()
|
||||
.all(|tool| tool.arguments.capacity() == 0));
|
||||
assert!(state.tool_results.is_empty());
|
||||
assert!(state.image_item_keys.is_empty());
|
||||
assert!(state
|
||||
.opaque_completed_item_keys
|
||||
.iter()
|
||||
.all(|key| matches!(key, OpenAIResponsesOutputItemKey::Digest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_terminal_observation_does_not_retain_long_stream_content() {
|
||||
let context = json!({"provider_api_format": "openai:responses"});
|
||||
let mut observed = OpenAIResponsesProviderState::terminal_observation();
|
||||
let mut full = OpenAIResponsesProviderState::default();
|
||||
let initial = json!({
|
||||
"type": "response.output_item.added", "output_index": 0,
|
||||
"item": {"type": "function_call", "id": "fc_1", "call_id": "call_1", "name": "read", "arguments": ""}
|
||||
});
|
||||
assert_eq!(
|
||||
observed.push_event(&context, &initial).unwrap(),
|
||||
terminal_frames(full.push_event(&context, &initial).unwrap())
|
||||
);
|
||||
let text = "x".repeat(1024);
|
||||
let events = [
|
||||
json!({"type": "response.output_text.delta", "delta": text, "output_index": 3}),
|
||||
json!({"type": "response.reasoning_summary_text.delta", "delta": text, "summary_index": 0}),
|
||||
json!({"type": "response.function_call_arguments.delta", "delta": text, "output_index": 0}),
|
||||
json!({"type": "response.custom_tool_call_input.delta", "delta": text, "output_index": 1}),
|
||||
json!({"type": "response.function_call_output.delta", "delta": text, "output_index": 2, "call_id": "call_1"}),
|
||||
];
|
||||
for iteration in 0..2048 {
|
||||
for event in &events {
|
||||
let frames = observed.push_event(&context, event).unwrap();
|
||||
assert!(frames.is_empty());
|
||||
if iteration < 32 {
|
||||
assert_eq!(
|
||||
frames,
|
||||
terminal_frames(full.push_event(&context, event).unwrap())
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_no_responses_observation_content(&observed);
|
||||
}
|
||||
assert_eq!(observed.tool_calls.len(), 2);
|
||||
assert_eq!(observed.tool_index_by_key, full.tool_index_by_key);
|
||||
assert!(full.text_parts.values().any(|text| text.len() == 32 * 1024));
|
||||
assert_eq!(full.reasoning.len(), 32 * 1024);
|
||||
assert_eq!(full.reasoning_parts[&0].len(), 32 * 1024);
|
||||
assert_eq!(full.tool_calls[&0].arguments.len(), 32 * 1024);
|
||||
assert!(!full.tool_results.is_empty());
|
||||
assert_eq!(
|
||||
observed.finish(&context).unwrap(),
|
||||
full.finish(&context).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_terminal_observation_skips_completed_content_and_images() {
|
||||
let context = json!({});
|
||||
let text = "x".repeat(64 * 1024);
|
||||
let items = [
|
||||
json!({"type": "message", "content": [{"type": "output_text", "text": text}]}),
|
||||
json!({"type": "reasoning", "summary": [{"type": "summary_text", "text": text}]}),
|
||||
json!({"type": "custom_tool_call", "input": text, "name": "custom"}),
|
||||
json!({"type": "shell_call", "action": {"command": text}}),
|
||||
json!({"type": "apply_patch_call", "operation": {"patch": text}}),
|
||||
json!({"type": "computer_call", "action": {"keys": [text]}}),
|
||||
json!({"type": "function_call_output", "output": text}),
|
||||
json!({"type": "custom_tool_call_output", "output": {"text": text}}),
|
||||
json!({"type": "image_generation_call", "result": text, "status": "completed"}),
|
||||
];
|
||||
let mut observed = OpenAIResponsesProviderState::terminal_observation();
|
||||
let mut full = OpenAIResponsesProviderState::default();
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
let event =
|
||||
json!({"type": "response.output_item.done", "output_index": index, "item": item});
|
||||
assert_eq!(
|
||||
observed.push_event(&context, &event).unwrap(),
|
||||
terminal_frames(full.push_event(&context, &event).unwrap())
|
||||
);
|
||||
assert_no_responses_observation_content(&observed);
|
||||
}
|
||||
let completed = json!({"type": "response.completed", "response": {
|
||||
"id": "resp_complete", "model": "model", "service_tier": "Priority",
|
||||
"output": items, "usage": {"input_tokens": 100, "output_tokens": 20, "input_tokens_details": {"cached_tokens": 0}}
|
||||
}});
|
||||
assert_eq!(
|
||||
observed.push_event(&context, &completed).unwrap(),
|
||||
terminal_frames(full.push_event(&context, &completed).unwrap())
|
||||
);
|
||||
assert_eq!(observed.actual_service_tier(), Some("priority"));
|
||||
assert_no_responses_observation_content(&observed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_terminal_observation_hashes_opaque_keys_without_changing_deduplication() {
|
||||
let context = json!({});
|
||||
let text = "x".repeat(64 * 1024);
|
||||
let items = [
|
||||
json!({"type": "future_item", "id": "id:with:separators", "encrypted_content": text}),
|
||||
json!({"type": "compaction", "encrypted_content": text}),
|
||||
json!({"type": "future_item", "payload": {"text": text, "escaped": "\n\"\\"}}),
|
||||
];
|
||||
let mut observed = OpenAIResponsesProviderState::terminal_observation();
|
||||
let mut full = OpenAIResponsesProviderState::default();
|
||||
for item in &items {
|
||||
let object = item.as_object().unwrap();
|
||||
let normal_key = OpenAIResponsesProviderState::output_item_key(object);
|
||||
let expected: [u8; 32] = Sha256::digest(normal_key.as_bytes()).into();
|
||||
let OpenAIResponsesOutputItemKey::Digest(actual) =
|
||||
observed.retained_output_item_key(object)
|
||||
else {
|
||||
panic!("observation must retain only an opaque item digest");
|
||||
};
|
||||
assert_eq!(actual, expected);
|
||||
let event = json!({"type": "response.output_item.done", "item": item});
|
||||
for _ in 0..2 {
|
||||
assert_eq!(
|
||||
observed.push_event(&context, &event).unwrap(),
|
||||
terminal_frames(full.push_event(&context, &event).unwrap())
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(observed.opaque_completed_item_keys.len(), items.len());
|
||||
assert_no_responses_observation_content(&observed);
|
||||
let mut final_items = items.to_vec();
|
||||
final_items.push(json!({"type": "future_item", "id": "new_item"}));
|
||||
let final_event =
|
||||
json!({"type": "response.completed", "response": {"output": final_items}});
|
||||
let frames = observed.push_event(&context, &final_event).unwrap();
|
||||
assert_eq!(
|
||||
frames,
|
||||
terminal_frames(full.push_event(&context, &final_event).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
frames
|
||||
.iter()
|
||||
.filter(|frame| matches!(frame.event, CanonicalStreamEvent::UnknownEvent(_)))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_responses_terminal_observation_preserves_tool_identity_validation() {
|
||||
let context = json!({"original_request_body": {"tools": [{
|
||||
"type": "namespace", "name": "reports", "description": "Reporting tools", "tools": [{
|
||||
"type": "function", "name": "write_report", "parameters": {"type": "object"}
|
||||
}]
|
||||
}]}});
|
||||
let expected_alias = NamespaceToolAliases::from_report_context(&context)
|
||||
.chat_name("reports", "write_report")
|
||||
.expect("fixture must contain a valid namespace tool")
|
||||
.to_owned();
|
||||
let mut observed = OpenAIResponsesProviderState::terminal_observation();
|
||||
let mut full = OpenAIResponsesProviderState::default();
|
||||
let events = [
|
||||
json!({"type": "response.function_call_arguments.delta", "item_id": "fc_1", "output_index": 0, "delta": "{"}),
|
||||
json!({"type": "response.output_item.added", "output_index": 0, "item": {
|
||||
"type": "function_call", "id": "fc_1", "namespace": "reports", "name": "write_report", "arguments": ""
|
||||
}}),
|
||||
json!({"type": "response.function_call_arguments.done", "item_id": "fc_1", "call_id": "call_1", "namespace": "reports", "arguments": "{}"}),
|
||||
json!({"type": "response.function_call_output.delta", "call_id": "call_1", "output_index": 7, "delta": "result"}),
|
||||
json!({"type": "response.function_call_arguments.done", "item_id": "fc_other", "name": "ordinary", "arguments": "{}"}),
|
||||
json!({"type": "response.function_call_arguments.done", "item_id": "fc_1", "namespace": "missing", "arguments": "{}"}),
|
||||
json!({"type": "response.output_item.done", "item": {
|
||||
"type": "function_call", "id": "invalid", "name": "read", "caller": {"type": "future"}, "arguments": "{}"
|
||||
}}),
|
||||
json!({"type": "response.output_item.done", "item": {"content": "missing type"}}),
|
||||
json!({"type": "response.future.delta", "payload": "unsupported"}),
|
||||
];
|
||||
let mut unknowns = 0;
|
||||
for (step, event) in events.into_iter().enumerate() {
|
||||
let frames = observed.push_event(&context, &event).unwrap();
|
||||
assert_eq!(
|
||||
frames,
|
||||
terminal_frames(full.push_event(&context, &event).unwrap())
|
||||
);
|
||||
unknowns += frames
|
||||
.iter()
|
||||
.filter(|frame| matches!(frame.event, CanonicalStreamEvent::UnknownEvent(_)))
|
||||
.count();
|
||||
assert_eq!(observed.tool_index_by_key, full.tool_index_by_key);
|
||||
assert_eq!(observed.last_tool_index, full.last_tool_index);
|
||||
assert_eq!(observed.tool_calls.len(), full.tool_calls.len());
|
||||
for (index, tool) in &observed.tool_calls {
|
||||
assert_eq!(tool.name, full.tool_calls[index].name);
|
||||
assert_eq!(tool.call_id, full.tool_calls[index].call_id);
|
||||
}
|
||||
if step == 1 || step == 2 {
|
||||
assert_eq!(unknowns, 0);
|
||||
assert_eq!(observed.tool_calls[&0].name, expected_alias);
|
||||
assert_eq!(
|
||||
observed.tool_calls[&0].call_id,
|
||||
if step == 1 { "" } else { "call_1" }
|
||||
);
|
||||
}
|
||||
assert_no_responses_observation_content(&observed);
|
||||
}
|
||||
assert_eq!(unknowns, 4);
|
||||
assert_eq!(
|
||||
observed.finish(&context).unwrap(),
|
||||
full.finish(&context).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_chat_terminal_observation_does_not_buffer_arguments_before_identity() {
|
||||
let context = json!({});
|
||||
let mut observed = OpenAIChatProviderState::terminal_observation();
|
||||
let mut full = OpenAIChatProviderState::default();
|
||||
let delta = json!({"choices": [{"delta": {
|
||||
"content": "x".repeat(1024), "reasoning_content": "r".repeat(1024),
|
||||
"tool_calls": [{"index": 0, "function": {"arguments": "a".repeat(1024)}}]
|
||||
}}]});
|
||||
for iteration in 0..2048 {
|
||||
let frames = observed
|
||||
.push_line(&context, data_line(delta.clone()))
|
||||
.unwrap();
|
||||
if iteration < 32 {
|
||||
assert_eq!(
|
||||
frames,
|
||||
terminal_frames(full.push_line(&context, data_line(delta.clone())).unwrap())
|
||||
);
|
||||
}
|
||||
assert!(observed.tool_calls.is_empty());
|
||||
}
|
||||
assert_eq!(full.tool_calls[&0].pending_arguments.len(), 32 * 1024);
|
||||
for event in [
|
||||
json!({"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call_1", "function": {"name": "read", "arguments": "end"}}]}}]}),
|
||||
json!({"choices": [{"delta": {"future": "unknown"}}]}),
|
||||
json!({"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}),
|
||||
json!({"choices": [], "usage": {"prompt_tokens": 100, "completion_tokens": 20, "prompt_tokens_details": {"cached_tokens": 0}}, "service_tier": "Flex"}),
|
||||
] {
|
||||
assert_eq!(
|
||||
observed
|
||||
.push_line(&context, data_line(event.clone()))
|
||||
.unwrap(),
|
||||
terminal_frames(full.push_line(&context, data_line(event)).unwrap())
|
||||
);
|
||||
}
|
||||
assert_eq!(observed.actual_service_tier(), Some("flex"));
|
||||
assert!(observed.tool_calls.is_empty());
|
||||
assert_eq!(
|
||||
observed.finish(&context).unwrap(),
|
||||
full.finish(&context).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
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: ")) {
|
||||
|
||||
@@ -423,10 +423,26 @@ impl TerminalStreamParser {
|
||||
{
|
||||
return Some(Self::OpenAIImage(OpenAiImageStreamTerminalState::default()));
|
||||
}
|
||||
ProviderStreamParser::for_api_format(provider_api_format).map(Self::Standard)
|
||||
let provider = match ProviderStreamParser::for_api_format(provider_api_format)? {
|
||||
ProviderStreamParser::OpenAIChat(_) => {
|
||||
ProviderStreamParser::OpenAIChat(OpenAIChatProviderState::terminal_observation())
|
||||
}
|
||||
ProviderStreamParser::OpenAIResponses(_) => ProviderStreamParser::OpenAIResponses(
|
||||
OpenAIResponsesProviderState::terminal_observation(),
|
||||
),
|
||||
ProviderStreamParser::Gemini(_) => {
|
||||
ProviderStreamParser::Gemini(GeminiProviderState::terminal_observation())
|
||||
}
|
||||
provider @ ProviderStreamParser::Claude(_) => provider,
|
||||
};
|
||||
Some(Self::Standard(provider))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "terminal_observation_tests.rs"]
|
||||
mod terminal_observation_tests;
|
||||
|
||||
enum ProviderStreamParser {
|
||||
OpenAIChat(OpenAIChatProviderState),
|
||||
OpenAIResponses(OpenAIResponsesProviderState),
|
||||
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::{ProviderStreamParser, StreamingStandardTerminalObserver, TerminalStreamParser};
|
||||
|
||||
fn full_observer(context: &Value) -> StreamingStandardTerminalObserver {
|
||||
StreamingStandardTerminalObserver {
|
||||
provider: Some(TerminalStreamParser::Standard(
|
||||
ProviderStreamParser::for_api_format(context["provider_api_format"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// Compare every input prefix, including EOF, to catch changes in identity and terminal timing.
|
||||
fn assert_summaries_match(context: &Value, events: &[Value]) {
|
||||
let structured = context["provider_api_format"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("openai:responses");
|
||||
for end in 0..=events.len() {
|
||||
let mut compact = StreamingStandardTerminalObserver::default();
|
||||
let mut full = full_observer(context);
|
||||
let mut via_event = StreamingStandardTerminalObserver::default();
|
||||
for (index, event) in events[..end].iter().enumerate() {
|
||||
let line = format!("data: {event}\n").into_bytes();
|
||||
compact.push_line(context, line.clone()).unwrap();
|
||||
full.push_line(context, line).unwrap();
|
||||
assert_eq!(
|
||||
compact.latest_summary(),
|
||||
full.latest_summary(),
|
||||
"prefix {index}: {event}"
|
||||
);
|
||||
if structured {
|
||||
via_event.push_event(context, event).unwrap();
|
||||
assert_eq!(via_event.latest_summary(), full.latest_summary());
|
||||
}
|
||||
}
|
||||
let expected = full.finish(context).unwrap();
|
||||
assert_eq!(compact.finish(context).unwrap(), expected, "EOF at {end}");
|
||||
if structured {
|
||||
assert_eq!(via_event.finish(context).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn context(format: &str) -> Value {
|
||||
json!({"provider_api_format": format, "client_api_format": format, "mapped_model": "test-model"})
|
||||
}
|
||||
|
||||
fn completed(output: Vec<Value>) -> Value {
|
||||
json!({"type":"response.completed","response":{
|
||||
"id":"resp-final","model":"final-model","status":"completed","service_tier":" PRIORITY ",
|
||||
"output":output,"usage":{"input_tokens":11,"output_tokens":7,"total_tokens":18,
|
||||
"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":3}}
|
||||
}})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_content_snapshots_and_deltas_preserve_every_summary() {
|
||||
let events = vec![
|
||||
json!({"type":"response.output_text.delta","delta":""}),
|
||||
json!({"type":"response.output_text.delta","delta":"he","output_index":0}),
|
||||
json!({"type":"response.created","response":{"id":"late-id","model":"late-model"}}),
|
||||
json!({"type":"response.output_text.delta","delta":{"text":"hello"},"output_index":0}),
|
||||
json!({"type":"response.output_text.done","text":"hello","output_index":0}),
|
||||
json!({"type":"response.content_part.added","content_index":1,"part":{"type":"output_text","text":"another"}}),
|
||||
json!({"type":"response.content_part.done","content_index":1,"part":{"type":"output_text","text":"another part"}}),
|
||||
json!({"type":"response.refusal.delta","delta":"refuse"}),
|
||||
json!({"type":"response.refusal.done","refusal":"refused"}),
|
||||
json!({"type":"response.audio.transcript.delta","delta":"audio"}),
|
||||
json!({"type":"response.audio.transcript.done","transcript":"audio transcript"}),
|
||||
json!({"type":"response.reasoning_summary_text.delta","delta":"think","summary_index":0}),
|
||||
json!({"type":"response.reasoning_text.delta","delta":" again","summary_index":0}),
|
||||
json!({"type":"response.reasoning_summary_part.added","summary_index":1,"part":{"type":"summary_text","text":"second"}}),
|
||||
json!({"type":"response.reasoning_summary_part.done","summary_index":1,"part":{"type":"summary_text","text":"second thought"}}),
|
||||
json!({"type":"response.reasoning_summary_text.done","summary_index":0,"text":"think again"}),
|
||||
json!({"type":"response.reasoning_text.done","text":""}),
|
||||
completed(vec![
|
||||
json!({"type":"message","content":[{"type":"output_text","text":"hello"},{"type":"refusal","refusal":"refused"}]}),
|
||||
json!({"type":"reasoning","summary":[{"type":"summary_text","text":"think again"},{"type":"summary_text","text":"second thought"}]}),
|
||||
]),
|
||||
];
|
||||
for format in ["openai:responses", "openai:responses:compact"] {
|
||||
assert_summaries_match(&context(format), &events);
|
||||
for event in &events {
|
||||
assert_summaries_match(&context(format), std::slice::from_ref(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_tools_and_unknown_execution_fields_preserve_summaries() {
|
||||
let calls = vec![
|
||||
json!({"type":"function_call","call_id":"call-0","name":"lookup","arguments":"{\"x\":1}"}),
|
||||
json!({"type":"custom_tool_call","call_id":"call-1","name":"custom","input":"raw input"}),
|
||||
json!({"type":"shell_call","call_id":"call-2","action":{"commands":["pwd"]}}),
|
||||
json!({"type":"local_shell_call","call_id":"call-3","action":{"command":["pwd"]}}),
|
||||
json!({"type":"apply_patch_call","call_id":"call-4","operation":{"type":"update_file","path":"a","diff":"+b"}}),
|
||||
json!({"type":"computer_call","call_id":"call-5","action":{"type":"click","x":1,"y":2}}),
|
||||
json!({"type":"function_call","call_id":"bad-caller","name":"lookup","arguments":"{}","caller":{"type":"direct"}}),
|
||||
json!({"type":"function_call","call_id":"bad-namespace","namespace":42,"name":"lookup","arguments":"{}"}),
|
||||
];
|
||||
let mut events = vec![
|
||||
json!({"type":"response.function_call_arguments.delta","output_index":0,"delta":"{"}),
|
||||
json!({"type":"response.function_call_arguments.delta","output_index":0,"call_id":"call-0","delta":"\"x\":1}"}),
|
||||
json!({"type":"response.function_call_arguments.done","output_index":0,"item":{"call_id":"call-0","name":"lookup","arguments":"{\"x\":1}"}}),
|
||||
json!({"type":"response.function_call_arguments.done","output_index":0,"namespace":{},"arguments":"{}"}),
|
||||
json!({"type":"response.custom_tool_call_input.delta","output_index":1,"delta":"raw"}),
|
||||
json!({"type":"response.custom_tool_call_input.done","output_index":1,"input":"raw input"}),
|
||||
];
|
||||
for (index, item) in calls.iter().enumerate() {
|
||||
events.push(json!({"type":"response.output_item.added","output_index":index,"item":item}));
|
||||
events.push(json!({"type":"response.output_item.done","output_index":index,"item":item}));
|
||||
}
|
||||
for kind in [
|
||||
"function_call",
|
||||
"custom_tool_call",
|
||||
"shell_call",
|
||||
"local_shell_call",
|
||||
"apply_patch_call",
|
||||
"computer_call",
|
||||
] {
|
||||
events.push(json!({"type":format!("response.{kind}_output.delta"),"call_id":"result","delta":"result"}));
|
||||
events.push(json!({"type":format!("response.{kind}_output.done"),"call_id":"result","output":{"ok":true}}));
|
||||
events.push(json!({"type":"response.output_item.done","item":{"type":format!("{kind}_output"),"call_id":"result","output":"result complete"}}));
|
||||
}
|
||||
events.push(completed(calls));
|
||||
assert_summaries_match(&context("openai:responses"), &events);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_opaque_dedup_and_images_preserve_unknown_counts() {
|
||||
let items = vec![
|
||||
json!({"type":"future_item","id":"stable-id","payload":"first"}),
|
||||
json!({"type":"future_item","encrypted_content":"encrypted","payload":"second"}),
|
||||
json!({"type":"future_item","payload":{"no_id":true}}),
|
||||
json!({"type":"image_generation_call","result":"image data","status":"completed"}),
|
||||
json!({"type":"reasoning","summary":[],"encrypted_content":"reasoning"}),
|
||||
];
|
||||
let mut events = Vec::new();
|
||||
for item in &items {
|
||||
events.push(json!({"type":"response.output_item.added","item":item}));
|
||||
events.push(json!({"type":"response.output_item.done","item":item}));
|
||||
events.push(json!({"type":"response.output_item.done","item":item}));
|
||||
}
|
||||
events.push(json!({"type":"response.output_item.done","item":{"missing_type":true}}));
|
||||
let mut output = items;
|
||||
output[0]["payload"] = json!("changed body, same id");
|
||||
output[1]["payload"] = json!("changed body, same encrypted content");
|
||||
output.push(json!({"type":"new_future_item","payload":"never emitted"}));
|
||||
events.push(completed(output));
|
||||
let ctx = context("openai:responses");
|
||||
assert_summaries_match(&ctx, &events);
|
||||
let mut observer = StreamingStandardTerminalObserver::default();
|
||||
for event in &events {
|
||||
observer.push_event(&ctx, event).unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
observer.finish(&ctx).unwrap().unwrap().unknown_event_count,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_namespace_validation_keeps_existing_tool_identity() {
|
||||
let mut ctx = context("openai:responses");
|
||||
ctx["original_request_body"] = json!({"tools":[{
|
||||
"type":"namespace","name":"search","description":"Search tools","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]
|
||||
}]});
|
||||
let events = [
|
||||
json!({"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call","namespace":"search","name":"lookup","arguments":"{"}}),
|
||||
json!({"type":"response.function_call_arguments.delta","output_index":0,"delta":"}"}),
|
||||
json!({"type":"response.function_call_arguments.done","output_index":0,"namespace":"search","arguments":"{}"}),
|
||||
json!({"type":"response.function_call_arguments.done","output_index":0,"namespace":"missing","arguments":"{}"}),
|
||||
completed(vec![
|
||||
json!({"type":"function_call","call_id":"call","namespace":"search","name":"lookup","arguments":"{}"}),
|
||||
]),
|
||||
];
|
||||
assert_summaries_match(&ctx, &events);
|
||||
let mut observer = StreamingStandardTerminalObserver::default();
|
||||
for event in &events {
|
||||
observer.push_event(&ctx, event).unwrap();
|
||||
}
|
||||
let summary = observer.finish(&ctx).unwrap().unwrap();
|
||||
assert_eq!(summary.unknown_event_count, 1);
|
||||
assert_eq!(summary.finish_reason.as_deref(), Some("tool_calls"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_errors_zero_usage_and_event_type_lines_preserve_summaries() {
|
||||
for terminal in [
|
||||
json!({"type":"response.failed","response":{"status":"failed","error":{"message":"failed"},"usage":{"input_tokens":0,"output_tokens":0}}}),
|
||||
json!({"type":"error","error":{"type":"server_error","message":"failed"}}),
|
||||
json!({"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":2,"output_tokens":0}}}),
|
||||
json!({"type":"response.done","response":{"status":"completed","usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}),
|
||||
json!({"type":"response.completed","response":null}),
|
||||
] {
|
||||
let ctx = context("openai:responses");
|
||||
let events = vec![
|
||||
json!({"type":"response.future"}),
|
||||
json!({"type":"ping"}),
|
||||
terminal,
|
||||
];
|
||||
assert_summaries_match(&ctx, &events);
|
||||
let mut compact = StreamingStandardTerminalObserver::default();
|
||||
let mut full = full_observer(&ctx);
|
||||
for mut event in events {
|
||||
let kind = event.as_object_mut().unwrap().remove("type").unwrap();
|
||||
for line in [
|
||||
format!("event: {}\n", kind.as_str().unwrap()),
|
||||
format!("data: {event}\n"),
|
||||
"\n".to_string(),
|
||||
] {
|
||||
compact.push_line(&ctx, line.as_bytes().to_vec()).unwrap();
|
||||
full.push_line(&ctx, line.into_bytes()).unwrap();
|
||||
assert_eq!(compact.latest_summary(), full.latest_summary());
|
||||
}
|
||||
}
|
||||
assert_eq!(compact.finish(&ctx).unwrap(), full.finish(&ctx).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_delayed_tool_identity_and_usage_only_preserve_summaries() {
|
||||
let ctx = context("openai:chat");
|
||||
assert_summaries_match(
|
||||
&ctx,
|
||||
&[
|
||||
json!({"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{"}}]}}]}),
|
||||
json!({"id":"late-id","model":"late-model","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call","function":{"name":"lookup","arguments":"}"}}]}}]}),
|
||||
json!({"choices":[{"delta":{"content":"text","reasoning_content":"reason"}}],"service_tier":"priority"}),
|
||||
json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
|
||||
json!({"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}),
|
||||
],
|
||||
);
|
||||
for event in [
|
||||
json!({"usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}}),
|
||||
json!({"choices":[{"delta":{"tool_calls":"malformed"}}]}),
|
||||
json!({"choices":[{"delta":{"tool_calls":[null,{}, {"function":null}]}}]}),
|
||||
json!({"choices":[{"delta":{},"finish_reason":"future_reason"}]}),
|
||||
json!({"choices":[{"delta":{"future_content":true}}]}),
|
||||
] {
|
||||
assert_summaries_match(&ctx, &[event]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_content_tools_and_errors_preserve_summaries() {
|
||||
let ctx = context("gemini:generate_content");
|
||||
let parts = vec![
|
||||
json!({"text":"text"}),
|
||||
json!({"text":"reason","thought":true,"thoughtSignature":"sig"}),
|
||||
json!({"functionCall":{"id":"call","name":"lookup","args":{"x":1}}}),
|
||||
json!({"functionResponse":{"id":"call","name":"lookup","response":{"ok":true}}}),
|
||||
json!({"inlineData":{"mimeType":"image/png","data":"aW1hZ2U="}}),
|
||||
json!({"futureContent":"unknown"}),
|
||||
];
|
||||
let mut events = Vec::new();
|
||||
for part in &parts {
|
||||
let event = json!({"candidates":[{"content":{"parts":[part]}}]});
|
||||
events.push(event.clone());
|
||||
events.push(event.clone());
|
||||
assert_summaries_match(&ctx, &[event]);
|
||||
}
|
||||
events.push(json!({"responseId":"late-id","modelVersion":"late-model","candidates":[{"content":{"parts":parts},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":3,"totalTokenCount":8}}));
|
||||
assert_summaries_match(&ctx, &events);
|
||||
for reason in [
|
||||
"MALFORMED_FUNCTION_CALL",
|
||||
"SAFETY",
|
||||
"MAX_TOKENS",
|
||||
"FUTURE_REASON",
|
||||
] {
|
||||
assert_summaries_match(
|
||||
&ctx,
|
||||
&[
|
||||
json!({"response":{"candidates":[{"content":{"parts":[{"text":"partial"}]}}]}}),
|
||||
json!({"candidates":[{"content":{"parts":[]},"finishReason":reason}],"usageMetadata":{"promptTokenCount":0,"candidatesTokenCount":0}}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user