feat(gateway): persist OpenAI Responses continuation history

This commit is contained in:
ZheFox
2026-07-30 19:26:52 +08:00
parent 1ab4f079c9
commit 118f441029
25 changed files with 1881 additions and 84 deletions
+8 -1
View File
@@ -190,6 +190,10 @@ pub use crate::formats::{
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL,
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
},
history::{
hydrate_response_history, record_converted_response_history,
response_history_is_loaded, response_history_storage_key, ResponseHistoryRecord,
},
spec::{
resolve_stream_spec as resolve_openai_responses_stream_spec,
resolve_sync_spec as resolve_openai_responses_sync_spec, LocalOpenAiResponsesSpec,
@@ -207,6 +211,7 @@ pub use crate::formats::{
build_cross_format_openai_chat_request_body_with_model_directives,
build_cross_format_openai_responses_request_body,
build_cross_format_openai_responses_request_body_with_model_directives,
build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope,
build_local_openai_chat_request_body,
build_local_openai_chat_request_body_with_model_directives,
build_local_openai_responses_request_body,
@@ -269,7 +274,9 @@ pub use aether_ai_formats::formats::conversion::request::{
convert_openai_chat_request_to_openai_responses_request, extract_openai_text_content,
normalize_claude_request_to_openai_chat_request,
normalize_gemini_request_to_openai_chat_request,
normalize_openai_responses_request_to_openai_chat_request, parse_openai_tool_result_content,
normalize_openai_responses_request_to_openai_chat_request,
normalize_openai_responses_request_to_openai_chat_request_with_history_scope,
parse_openai_tool_result_content,
};
pub use aether_ai_formats::formats::conversion::response::{
build_openai_responses_response, build_openai_responses_response_with_content,
@@ -9,6 +9,7 @@ pub struct FormatContext {
pub request_path: Option<String>,
pub upstream_is_stream: bool,
pub report_context: Option<Value>,
pub history_scope: Option<String>,
}
impl FormatContext {
@@ -32,12 +33,18 @@ impl FormatContext {
self
}
pub fn with_history_scope(mut self, history_scope: impl Into<String>) -> Self {
self.history_scope = Some(history_scope.into());
self
}
pub fn without_runtime_request_edits(&self) -> Self {
Self {
mapped_model: None,
request_path: self.request_path.clone(),
upstream_is_stream: false,
report_context: self.report_context.clone(),
history_scope: self.history_scope.clone(),
}
}
@@ -59,13 +59,18 @@ pub fn convert_openai_chat_request_to_openai_responses_request(
pub fn normalize_openai_responses_request_to_openai_chat_request(
body_json: &Value,
) -> Option<Value> {
registry::convert_request(
"openai:responses",
"openai:chat",
body_json,
&FormatContext::default(),
)
.ok()
normalize_openai_responses_request_to_openai_chat_request_with_history_scope(body_json, None)
}
pub fn normalize_openai_responses_request_to_openai_chat_request_with_history_scope(
body_json: &Value,
history_scope: Option<&str>,
) -> Option<Value> {
let mut context = FormatContext::default();
if let Some(history_scope) = history_scope {
context = context.with_history_scope(history_scope);
}
registry::convert_request("openai:responses", "openai:chat", body_json, &context).ok()
}
pub fn normalize_claude_request_to_openai_chat_request(body_json: &Value) -> Option<Value> {
@@ -21,6 +21,7 @@ fn normalize_openai_service_tier(value: Option<&str>) -> Option<String> {
struct OpenAIChatProviderToolState {
id: Option<String>,
name: Option<String>,
pending_arguments: String,
started_emitted: bool,
}
@@ -279,59 +280,54 @@ impl OpenAIChatProviderState {
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 !state.started_emitted && (state.id.is_some() || state.name.is_some()) {
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::ToolCallStart {
index,
call_id: state
.id
.clone()
.unwrap_or_else(|| build_generated_tool_call_id(index)),
name: state
.name
.clone()
.unwrap_or_else(|| "unknown".to_string()),
call_id,
name,
},
});
state.started_emitted = true;
}
if let Some(arguments) = function.get("arguments").and_then(Value::as_str) {
if !arguments.is_empty() {
if !state.started_emitted {
out.push(CanonicalStreamFrame {
id: id.clone(),
model: model.clone(),
event: CanonicalStreamEvent::ToolCallStart {
index,
call_id: state.id.clone().unwrap_or_else(|| {
build_generated_tool_call_id(index)
}),
name: state
.name
.clone()
.unwrap_or_else(|| "unknown".to_string()),
},
});
state.started_emitted = true;
}
if !state.pending_arguments.is_empty() {
out.push(CanonicalStreamFrame {
id: id.clone(),
model: model.clone(),
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index,
arguments: arguments.to_string(),
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 delta.contains_key("tool_calls") {
@@ -651,6 +647,22 @@ impl OpenAIResponsesProviderState {
});
}
fn emit_ready_function_call(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
index: usize,
) {
if self
.tool_calls
.get(&index)
.is_none_or(|state| state.call_id.trim().is_empty())
{
return;
}
self.emit_ready_tool_call(report_context, out, index);
}
fn merge_tool_call_arguments(state: &mut OpenAIResponsesProviderToolState, arguments: &str) {
if arguments.is_empty() {
return;
@@ -715,7 +727,6 @@ impl OpenAIResponsesProviderState {
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();
@@ -730,7 +741,7 @@ impl OpenAIResponsesProviderState {
.unwrap_or_default()
.to_string();
Self::merge_tool_call_arguments(state, &completed_arguments);
self.emit_ready_tool_call(report_context, out, index);
self.emit_ready_function_call(report_context, out, index);
}
fn emit_custom_tool_call_item(
@@ -1553,21 +1564,11 @@ impl OpenAIResponsesProviderState {
.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 let Some(call_id) = value
.get("call_id")
.or_else(|| value.get("id"))
.and_then(Value::as_str)
{
if let Some(call_id) = value.get("call_id").and_then(Value::as_str) {
state.call_id = call_id.to_string();
} else if state.call_id.is_empty() {
state.call_id = value
.get("item_id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
}
state.arguments.push_str(delta);
self.emit_ready_tool_call(report_context, &mut out, index);
self.emit_ready_function_call(report_context, &mut out, index);
}
"response.function_call_arguments.done" => {
let arguments = value
@@ -1604,16 +1605,14 @@ impl OpenAIResponsesProviderState {
let state = self.tool_calls.entry(index).or_default();
state.call_id = value
.get("call_id")
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.or_else(|| {
value
.get("item")
.and_then(Value::as_object)
.and_then(|item| item.get("call_id").or_else(|| item.get("id")))
.and_then(|item| item.get("call_id"))
.and_then(Value::as_str)
})
.or_else(|| value.get("item_id").and_then(Value::as_str))
.unwrap_or(state.call_id.as_str())
.to_string();
state.name = value
@@ -1629,7 +1628,7 @@ impl OpenAIResponsesProviderState {
.unwrap_or(state.name.as_str())
.to_string();
Self::merge_tool_call_arguments(state, arguments);
self.emit_ready_tool_call(report_context, &mut out, index);
self.emit_ready_function_call(report_context, &mut out, index);
}
"response.function_call_output.delta" | "response.function_call_output.done" => {
let tool_use_id = value
@@ -1896,6 +1895,7 @@ pub struct OpenAIResponsesClientEmitter {
image_generation_items: BTreeMap<usize, Value>,
opaque_output_items: BTreeMap<usize, Value>,
opaque_output_indexes: BTreeMap<String, usize>,
completed_history_response: Option<Value>,
}
impl OpenAIChatClientEmitter {
@@ -2210,6 +2210,10 @@ impl OpenAIResponsesClientEmitter {
self.response_id.as_deref().unwrap_or("resp-local-stream")
}
pub(crate) fn completed_response_for_history(&self) -> Option<&Value> {
self.completed_history_response.as_ref()
}
fn model(&self) -> &str {
self.model.as_deref().unwrap_or("unknown")
}
@@ -2226,6 +2230,14 @@ impl OpenAIResponsesClientEmitter {
.unwrap_or_else(|| format!("{}_rs_0", self.response_id()))
}
fn tool_call_item_id(&self, index: usize) -> String {
format!(
"fc_{}_{}",
self.response_id().trim_start_matches("resp_"),
index
)
}
fn ensure_message_item_id(&mut self) -> String {
if self.message_item_id.is_none() {
self.message_item_id = Some(format!("{}_msg", self.response_id()));
@@ -2601,11 +2613,12 @@ impl OpenAIResponsesClientEmitter {
for index in indices {
let output_index = self.ensure_tool_output_index(index);
let state = self.tool_calls.get(&index).cloned().unwrap_or_default();
let item_id = if state.call_id.is_empty() {
let call_id = if state.call_id.is_empty() {
build_generated_tool_call_id(index)
} else {
state.call_id.clone()
};
let item_id = self.tool_call_item_id(index);
let name = if state.name.is_empty() {
"unknown".to_string()
} else {
@@ -2638,7 +2651,7 @@ impl OpenAIResponsesClientEmitter {
"response_id": self.response_id(),
"output_index": output_index,
"item_id": item_id.clone(),
"call_id": item_id.clone(),
"call_id": call_id.clone(),
"name": name,
"arguments": state.arguments.as_str(),
}),
@@ -2652,7 +2665,7 @@ impl OpenAIResponsesClientEmitter {
"item": {
"type": "function_call",
"id": item_id.clone(),
"call_id": item_id,
"call_id": call_id,
"name": name,
"arguments": state.arguments.as_str(),
"status": "completed",
@@ -2795,11 +2808,12 @@ impl OpenAIResponsesClientEmitter {
}
for (index, state) in &self.tool_calls {
if let Some(output_index) = state.output_index {
let item_id = if state.call_id.is_empty() {
let call_id = if state.call_id.is_empty() {
build_generated_tool_call_id(*index)
} else {
state.call_id.clone()
};
let item_id = self.tool_call_item_id(*index);
if state.web_search {
ordered_output.push((
output_index,
@@ -2820,7 +2834,7 @@ impl OpenAIResponsesClientEmitter {
json!({
"type": "function_call",
"id": item_id.clone(),
"call_id": item_id,
"call_id": call_id,
"name": if state.name.is_empty() {
"unknown".to_string()
} else {
@@ -2948,6 +2962,9 @@ impl OpenAIResponsesClientEmitter {
out.extend(self.finish_text_item()?);
out.extend(self.finish_tool_items()?);
out.extend(self.finish_tool_result_items()?);
if event_type == "response.completed" {
self.completed_history_response = Some(response.clone());
}
out.extend(self.encode_response_event(
event_type,
json!({
@@ -3110,6 +3127,7 @@ impl OpenAIResponsesClientEmitter {
let mut out = self.ensure_started()?;
let output_index = self.ensure_tool_output_index(index);
let response_id = self.response_id().to_string();
let item_id = self.tool_call_item_id(index);
let state = self.tool_calls.entry(index).or_default();
state.call_id = call_id.clone();
state.name = name.clone();
@@ -3119,7 +3137,7 @@ impl OpenAIResponsesClientEmitter {
let item = if state.web_search {
json!({
"type": "web_search_call",
"id": emitted_call_id,
"id": item_id,
"status": "in_progress",
"action": {
"type": "search",
@@ -3129,7 +3147,7 @@ impl OpenAIResponsesClientEmitter {
} else {
json!({
"type": "function_call",
"id": call_id,
"id": item_id,
"call_id": emitted_call_id,
"name": emitted_name,
"arguments": "",
@@ -3156,19 +3174,20 @@ impl OpenAIResponsesClientEmitter {
if state.web_search {
return Ok(out);
}
let item_id = if state.call_id.is_empty() {
let call_id = if state.call_id.is_empty() {
build_generated_tool_call_id(index)
} else {
state.call_id.clone()
};
let item_id = self.tool_call_item_id(index);
out.extend(self.encode_response_event(
"response.function_call_arguments.delta",
json!({
"type": "response.function_call_arguments.delta",
"response_id": response_id,
"output_index": output_index,
"item_id": item_id.clone(),
"call_id": item_id,
"item_id": item_id,
"call_id": call_id,
"delta": arguments,
}),
)?);
@@ -3297,6 +3316,9 @@ impl OpenAIResponsesClientEmitter {
),
_ => ("response.completed", self.completed_response(usage)),
};
if event_type == "response.completed" {
self.completed_history_response = Some(response.clone());
}
out.extend(self.encode_response_event(
event_type,
json!({
@@ -3623,6 +3645,81 @@ mod tests {
)));
}
#[test]
fn openai_chat_provider_state_waits_for_real_tool_call_identity() {
let mut state = OpenAIChatProviderState::default();
let report_context = json!({});
let mut frames = state
.push_line(
&report_context,
data_line(json!({
"id": "chatcmpl_tool_123",
"model": "deepseek-v4-flash",
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": 0,
"function": {"arguments": "{\"path\":"}
}]
}
}]
})),
)
.expect("arguments-first delta should parse");
frames.extend(
state
.push_line(
&report_context,
data_line(json!({
"id": "chatcmpl_tool_123",
"model": "deepseek-v4-flash",
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": 0,
"function": {"name": "security_scan", "arguments": "\"src\"}"}
}]
}
}]
})),
)
.expect("name delta should parse"),
);
assert!(!frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart { .. }
| CanonicalStreamEvent::ToolCallArgumentsDelta { .. }
)));
let identified = state
.push_line(
&report_context,
data_line(json!({
"id": "chatcmpl_tool_123",
"model": "deepseek-v4-flash",
"choices": [{
"index": 0,
"delta": {"tool_calls": [{"index": 0, "id": "call_security_123"}]}
}]
})),
)
.expect("id delta should parse");
assert!(matches!(
identified.first().map(|frame| &frame.event),
Some(CanonicalStreamEvent::ToolCallStart { call_id, name, .. })
if call_id == "call_security_123" && name == "security_scan"
));
assert!(matches!(
identified.get(1).map(|frame| &frame.event),
Some(CanonicalStreamEvent::ToolCallArgumentsDelta { arguments, .. })
if arguments == "{\"path\":\"src\"}"
));
}
#[test]
fn openai_responses_provider_state_emits_unknown_events_for_unknown_response_types() {
let mut state = OpenAIResponsesProviderState::default();
@@ -4543,6 +4640,91 @@ mod tests {
assert!(!sse.contains("\\\"pages\\\":\\\"\\\""));
}
#[test]
fn openai_responses_provider_state_waits_for_call_id_distinct_from_item_id() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let item_frames = state
.push_line(
&report_context,
data_line(json!({
"type": "response.output_item.added",
"response_id": "resp_delayed_call_id",
"output_index": 0,
"item": {
"type": "function_call",
"id": "fc_delayed_call_id",
"name": "Read",
"arguments": ""
}
})),
)
.expect("function-call item should parse");
assert!(!item_frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart { .. }
| CanonicalStreamEvent::ToolCallArgumentsDelta { .. }
)));
let delta_frames = state
.push_line(
&report_context,
data_line(json!({
"type": "response.function_call_arguments.delta",
"response_id": "resp_delayed_call_id",
"output_index": 0,
"item_id": "fc_delayed_call_id",
"delta": "{\"path\":"
})),
)
.expect("arguments delta should be buffered");
assert!(!delta_frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart { .. }
| CanonicalStreamEvent::ToolCallArgumentsDelta { .. }
)));
let identity_frames = state
.push_line(
&report_context,
data_line(json!({
"type": "response.function_call_arguments.done",
"response_id": "resp_delayed_call_id",
"output_index": 0,
"item_id": "fc_delayed_call_id",
"item": {
"type": "function_call",
"id": "fc_delayed_call_id",
"call_id": "call_delayed_call_id",
"name": "Read",
"arguments": "{\"path\":\"src/lib.rs\"}"
},
"arguments": "{\"path\":\"src/lib.rs\"}"
})),
)
.expect("real call identity should flush buffered arguments");
assert!(identity_frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart {
ref call_id,
ref name,
..
} if call_id == "call_delayed_call_id" && name == "Read"
)));
assert!(!identity_frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart { ref call_id, .. }
if call_id == "fc_delayed_call_id"
)));
assert!(identity_frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallArgumentsDelta { ref arguments, .. }
if arguments == "{\"path\":\"src/lib.rs\"}"
)));
}
#[test]
fn openai_responses_provider_state_parses_function_call_output_as_tool_result() {
let mut state = OpenAIResponsesProviderState::default();
@@ -4825,6 +5007,68 @@ mod tests {
assert!(sse.contains("\"output\":\"{\\\"ok\\\":true}\""));
}
#[test]
fn openai_responses_client_emitter_keeps_call_id_distinct_and_stable() {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "resp_tool_identity_123".to_string(),
model: "deepseek-v4-flash".to_string(),
event: CanonicalStreamEvent::ToolCallStart {
index: 0,
call_id: "call_security_123".to_string(),
name: "security_scan".to_string(),
},
})
.expect("tool start should encode");
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_tool_identity_123".to_string(),
model: "deepseek-v4-flash".to_string(),
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index: 0,
arguments: "{\"depth\":\"deep\"}".to_string(),
},
})
.expect("tool arguments should encode"),
);
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_tool_identity_123".to_string(),
model: "deepseek-v4-flash".to_string(),
event: CanonicalStreamEvent::Finish {
finish_reason: Some("tool_calls".to_string()),
usage: None,
},
})
.expect("tool finish should encode"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
let item_id = "fc_tool_identity_123_0";
assert!(sse.contains(&format!("\"id\":\"{item_id}\"")));
assert!(sse.contains(&format!("\"item_id\":\"{item_id}\"")));
assert!(sse.contains("\"call_id\":\"call_security_123\""));
assert!(!sse.contains("\"id\":\"call_security_123\""));
assert!(
sse.find("event: response.output_item.added")
< sse.find("event: response.function_call_arguments.delta")
);
assert!(
sse.find("event: response.function_call_arguments.delta")
< sse.find("event: response.function_call_arguments.done")
);
assert!(
sse.find("event: response.function_call_arguments.done")
< sse.find("event: response.output_item.done")
);
assert!(
sse.find("event: response.output_item.done") < sse.find("event: response.completed")
);
}
#[test]
fn openai_responses_client_emitter_emits_web_search_call_item() {
let mut emitter = OpenAIResponsesClientEmitter::default();
@@ -0,0 +1,815 @@
use std::{
collections::{HashMap, VecDeque},
sync::{Mutex, OnceLock},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use crate::formats::{context::FormatContext, registry};
const HISTORY_TTL: Duration = Duration::from_secs(6 * 60 * 60);
const MAX_HISTORY_ENTRIES: usize = 2_048;
const MAX_HISTORY_BYTES: usize = 64 * 1024 * 1024;
const MAX_HISTORY_ENTRY_BYTES: usize = 8 * 1024 * 1024;
const HISTORY_STORAGE_VERSION: u8 = 1;
const HISTORY_STORAGE_KEY_PREFIX: &str = "ai:responses:history:v1";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResponseHistoryRecord {
pub storage_key: String,
pub payload: String,
pub ttl: Duration,
}
#[derive(Serialize, Deserialize)]
struct PersistedResponseHistory {
version: u8,
response_id: String,
scope_fingerprint: String,
expires_at_unix_secs: u64,
transcript: Vec<Value>,
}
#[derive(Clone)]
struct ResponseHistoryEntry {
transcript: Vec<Value>,
inserted_at: Instant,
expires_at: Instant,
size_bytes: usize,
}
#[derive(Clone, Hash, PartialEq, Eq)]
struct ResponseHistoryKey {
scope: Option<String>,
response_id: String,
}
#[derive(Default)]
struct ResponseHistoryStore {
entries: HashMap<ResponseHistoryKey, ResponseHistoryEntry>,
insertion_order: VecDeque<(ResponseHistoryKey, Instant)>,
total_bytes: usize,
}
impl ResponseHistoryStore {
fn remove(&mut self, key: &ResponseHistoryKey) {
if let Some(entry) = self.entries.remove(key) {
self.total_bytes = self.total_bytes.saturating_sub(entry.size_bytes);
}
}
fn prune(&mut self, now: Instant) {
let expired_keys = self
.entries
.iter()
.filter(|(_, entry)| entry.expires_at <= now)
.map(|(key, _)| key.clone())
.collect::<Vec<_>>();
for key in expired_keys {
self.remove(&key);
}
while self.entries.len() > MAX_HISTORY_ENTRIES || self.total_bytes > MAX_HISTORY_BYTES {
let Some((key, inserted_at)) = self.insertion_order.pop_front() else {
break;
};
if self
.entries
.get(&key)
.is_some_and(|entry| entry.inserted_at == inserted_at)
{
self.remove(&key);
}
}
while let Some((key, inserted_at)) = self.insertion_order.front() {
if self
.entries
.get(key)
.is_some_and(|entry| entry.inserted_at == *inserted_at)
{
break;
}
self.insertion_order.pop_front();
}
}
fn get(
&mut self,
response_id: &str,
history_scope: Option<&str>,
now: Instant,
) -> Option<Vec<Value>> {
self.prune(now);
self.entries
.get(&response_history_key(response_id, history_scope))
.map(|entry| entry.transcript.clone())
}
fn insert(
&mut self,
response_id: String,
history_scope: Option<&str>,
transcript: Vec<Value>,
now: Instant,
ttl: Duration,
) {
if ttl.is_zero() {
return;
}
let size_bytes = serde_json::to_vec(&transcript)
.map(|bytes| bytes.len())
.unwrap_or(MAX_HISTORY_ENTRY_BYTES.saturating_add(1));
if size_bytes > MAX_HISTORY_ENTRY_BYTES {
return;
}
let key = response_history_key(&response_id, history_scope);
self.remove(&key);
self.total_bytes = self.total_bytes.saturating_add(size_bytes);
self.entries.insert(
key.clone(),
ResponseHistoryEntry {
transcript,
inserted_at: now,
expires_at: now.checked_add(ttl).unwrap_or(now),
size_bytes,
},
);
self.insertion_order.push_back((key, now));
self.prune(now);
}
}
fn response_history_key(response_id: &str, history_scope: Option<&str>) -> ResponseHistoryKey {
ResponseHistoryKey {
scope: history_scope
.map(str::trim)
.filter(|scope| !scope.is_empty())
.map(ToOwned::to_owned),
response_id: response_id.to_string(),
}
}
fn normalized_history_scope(history_scope: Option<&str>) -> Option<&str> {
history_scope
.map(str::trim)
.filter(|scope| !scope.is_empty())
}
fn sha256_hex(value: &[u8]) -> String {
Sha256::digest(value)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn history_scope_fingerprint(history_scope: Option<&str>) -> String {
sha256_hex(
normalized_history_scope(history_scope)
.unwrap_or("<unscoped>")
.as_bytes(),
)
}
pub fn response_history_storage_key(response_id: &str, history_scope: Option<&str>) -> String {
let mut hasher = Sha256::new();
hasher.update(history_scope_fingerprint(history_scope));
hasher.update([0]);
hasher.update(response_id.trim().as_bytes());
let digest = hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
format!("{HISTORY_STORAGE_KEY_PREFIX}:{digest}")
}
fn current_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn response_history_store() -> &'static Mutex<ResponseHistoryStore> {
static STORE: OnceLock<Mutex<ResponseHistoryStore>> = OnceLock::new();
STORE.get_or_init(|| Mutex::new(ResponseHistoryStore::default()))
}
pub fn response_history_is_loaded(response_id: &str, history_scope: Option<&str>) -> bool {
response_history_store()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.get(response_id, history_scope, Instant::now())
.is_some()
}
pub fn hydrate_response_history(
response_id: &str,
history_scope: Option<&str>,
payload: &str,
) -> Result<(), String> {
if payload.len() > MAX_HISTORY_ENTRY_BYTES {
return Err("persisted response history exceeds the maximum entry size".to_string());
}
let persisted: PersistedResponseHistory = serde_json::from_str(payload)
.map_err(|error| format!("invalid persisted response history: {error}"))?;
if persisted.version != HISTORY_STORAGE_VERSION {
return Err(format!(
"unsupported response history version {}",
persisted.version
));
}
if persisted.response_id != response_id.trim() {
return Err(
"persisted response history id does not match the requested response".to_string(),
);
}
if persisted.scope_fingerprint != history_scope_fingerprint(history_scope) {
return Err("persisted response history scope does not match the requester".to_string());
}
let now_unix_secs = current_unix_secs();
let remaining_ttl = persisted
.expires_at_unix_secs
.checked_sub(now_unix_secs)
.filter(|seconds| *seconds > 0)
.map(Duration::from_secs)
.ok_or_else(|| "persisted response history has expired".to_string())?;
response_history_store()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(
response_id.trim().to_string(),
history_scope,
persisted.transcript,
Instant::now(),
remaining_ttl.min(HISTORY_TTL),
);
Ok(())
}
fn request_input_items(request: &Value) -> Vec<Value> {
match request.get("input") {
Some(Value::Array(items)) => items.clone(),
Some(Value::String(text)) if !text.is_empty() => vec![json!({
"type": "message",
"role": "user",
"content": text,
})],
_ if request.get("messages").and_then(Value::as_array).is_some() => {
registry::convert_request(
"openai:chat",
"openai:responses",
request,
&FormatContext::default(),
)
.ok()
.and_then(|converted| converted.get("input").and_then(Value::as_array).cloned())
.unwrap_or_default()
}
_ => Vec::new(),
}
}
fn history_conversion_enabled(report_context: &Value) -> bool {
report_context
.get("needs_conversion")
.and_then(Value::as_bool)
.unwrap_or(false)
&& report_context
.get("client_api_format")
.and_then(Value::as_str)
.is_some_and(|format| format.eq_ignore_ascii_case("openai:responses"))
&& report_context
.get("provider_api_format")
.and_then(Value::as_str)
.is_some_and(|format| format.eq_ignore_ascii_case("openai:chat"))
}
pub(crate) fn expand_previous_response_for_chat(
request: &Value,
history_scope: Option<&str>,
) -> Result<Value, String> {
let Some(previous_response_id) = request
.get("previous_response_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(request.clone());
};
let mut store = response_history_store()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let Some(mut transcript) = store.get(previous_response_id, history_scope, Instant::now())
else {
return Err(format!(
"response history not found for previous_response_id {previous_response_id}"
));
};
transcript.extend(request_input_items(request));
let mut expanded = request.clone();
let Some(object) = expanded.as_object_mut() else {
return Err("OpenAI Responses request must be a JSON object".to_string());
};
object.remove("previous_response_id");
object.insert("input".to_string(), Value::Array(transcript));
Ok(expanded)
}
pub fn record_converted_response_history(
report_context: &Value,
response: &Value,
) -> Option<ResponseHistoryRecord> {
if !history_conversion_enabled(report_context)
|| response.get("status").and_then(Value::as_str) != Some("completed")
{
return None;
}
let Some(response_id) = response
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return None;
};
let Some(request) = report_context.get("original_request_body") else {
return None;
};
let history_scope = report_context
.get("api_key_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|scope| !scope.is_empty());
let mut transcript = if request.get("messages").and_then(Value::as_array).is_some() {
Vec::new()
} else {
request
.get("previous_response_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.and_then(|previous_response_id| {
response_history_store()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.get(previous_response_id, history_scope, Instant::now())
})
.unwrap_or_default()
};
transcript.extend(request_input_items(request));
if let Some(output) = response.get("output").and_then(Value::as_array) {
transcript.extend(output.iter().cloned());
}
let expires_at_unix_secs = current_unix_secs().saturating_add(HISTORY_TTL.as_secs());
let payload = serde_json::to_string(&PersistedResponseHistory {
version: HISTORY_STORAGE_VERSION,
response_id: response_id.to_string(),
scope_fingerprint: history_scope_fingerprint(history_scope),
expires_at_unix_secs,
transcript: transcript.clone(),
})
.ok()?;
if payload.len() > MAX_HISTORY_ENTRY_BYTES {
return None;
}
response_history_store()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(
response_id.to_string(),
history_scope,
transcript,
Instant::now(),
HISTORY_TTL,
);
Some(ResponseHistoryRecord {
storage_key: response_history_storage_key(response_id, history_scope),
payload,
ttl: HISTORY_TTL,
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::formats::{context::FormatContext, registry::convert_request};
use super::{
expand_previous_response_for_chat, hydrate_response_history,
record_converted_response_history, response_history_storage_key, response_history_store,
ResponseHistoryStore,
};
fn conversion_report_context(original_request_body: serde_json::Value) -> serde_json::Value {
json!({
"needs_conversion": true,
"client_api_format": "openai:responses",
"provider_api_format": "openai:chat",
"original_request_body": original_request_body,
})
}
#[test]
fn expands_previous_response_with_assistant_call_before_tool_output() {
let report_context = conversion_report_context(json!({
"model": "deepseek-v4-flash",
"input": [{"role": "user", "content": "scan the repository"}]
}));
record_converted_response_history(
&report_context,
&json!({
"id": "resp_history_expand_test_1",
"status": "completed",
"output": [{
"type": "function_call",
"id": "fc_history_expand_test_1",
"call_id": "call_history_expand_test_1",
"name": "security_scan",
"arguments": "{\"depth\":\"deep\"}"
}]
}),
);
let expanded = expand_previous_response_for_chat(
&json!({
"model": "deepseek-v4-flash",
"previous_response_id": "resp_history_expand_test_1",
"input": [{
"type": "function_call_output",
"call_id": "call_history_expand_test_1",
"output": "manifest-created"
}]
}),
None,
)
.expect("stored previous response should expand");
assert!(expanded.get("previous_response_id").is_none());
assert_eq!(expanded["input"][1]["type"], "function_call");
assert_eq!(expanded["input"][1]["id"], "fc_history_expand_test_1");
assert_eq!(
expanded["input"][1]["call_id"],
"call_history_expand_test_1"
);
assert_eq!(expanded["input"][2]["type"], "function_call_output");
assert_eq!(
expanded["input"][2]["call_id"],
"call_history_expand_test_1"
);
}
#[test]
fn restores_persisted_history_after_local_cache_reset() {
let report_context = json!({
"needs_conversion": true,
"client_api_format": "openai:responses",
"provider_api_format": "openai:chat",
"api_key_id": "distributed-history-key-a",
"original_request_body": {
"model": "deepseek-v4-flash",
"input": [{"role": "user", "content": "inspect the repository"}]
}
});
let record = record_converted_response_history(
&report_context,
&json!({
"id": "resp_distributed_history_1",
"status": "completed",
"output": [{
"type": "function_call",
"id": "fc_distributed_history_1",
"call_id": "call_distributed_history_1",
"name": "inspect_repository",
"arguments": "{}"
}]
}),
)
.expect("completed conversion should produce a persistence record");
assert_eq!(
record.storage_key,
response_history_storage_key(
"resp_distributed_history_1",
Some("distributed-history-key-a")
)
);
assert!(!record.storage_key.contains("distributed-history-key-a"));
assert!(!record.storage_key.contains("resp_distributed_history_1"));
*response_history_store()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = ResponseHistoryStore::default();
let continuation = json!({
"model": "deepseek-v4-flash",
"previous_response_id": "resp_distributed_history_1",
"input": [{
"type": "function_call_output",
"call_id": "call_distributed_history_1",
"output": "inspection-complete"
}]
});
assert!(expand_previous_response_for_chat(
&continuation,
Some("distributed-history-key-a")
)
.is_err());
hydrate_response_history(
"resp_distributed_history_1",
Some("distributed-history-key-a"),
&record.payload,
)
.expect("another instance should hydrate the persisted transcript");
let expanded =
expand_previous_response_for_chat(&continuation, Some("distributed-history-key-a"))
.expect("hydrated history should support the continuation");
assert_eq!(
expanded["input"][1]["call_id"],
"call_distributed_history_1"
);
assert_eq!(
expanded["input"][2]["call_id"],
"call_distributed_history_1"
);
}
#[test]
fn restores_history_recorded_from_redacted_chat_messages() {
record_converted_response_history(
&conversion_report_context(json!({
"model": "deepseek-v4-flash",
"messages": [
{"role": "user", "content": "scan the redacted repository"},
{
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_redacted_previous_1",
"type": "function",
"function": {
"name": "security_scan",
"arguments": "{\"depth\":\"quick\"}"
}
}]
},
{
"role": "tool",
"tool_call_id": "call_redacted_previous_1",
"content": "quick-scan-complete"
}
]
})),
&json!({
"id": "resp_history_redacted_test_1",
"status": "completed",
"output": [{
"type": "function_call",
"id": "fc_history_redacted_test_1",
"call_id": "call_redacted_next_1",
"name": "deep_scan",
"arguments": "{\"scope\":\"changed-files\"}"
}]
}),
);
let expanded = expand_previous_response_for_chat(
&json!({
"model": "deepseek-v4-flash",
"previous_response_id": "resp_history_redacted_test_1",
"input": [{
"type": "function_call_output",
"call_id": "call_redacted_next_1",
"output": "deep-scan-complete"
}]
}),
None,
)
.expect("redacted Chat history should expand");
let input = expanded["input"]
.as_array()
.expect("expanded input should be an array");
assert_eq!(input.len(), 5);
assert_eq!(input[1]["type"], "function_call");
assert_eq!(input[1]["call_id"], "call_redacted_previous_1");
assert_eq!(input[2]["type"], "function_call_output");
assert_eq!(input[2]["call_id"], "call_redacted_previous_1");
assert_eq!(input[3]["id"], "fc_history_redacted_test_1");
assert_eq!(input[3]["call_id"], "call_redacted_next_1");
assert_eq!(input[4]["type"], "function_call_output");
assert_eq!(input[4]["call_id"], "call_redacted_next_1");
}
#[test]
fn isolates_response_history_by_api_key_scope() {
let report_context = json!({
"needs_conversion": true,
"client_api_format": "openai:responses",
"provider_api_format": "openai:chat",
"api_key_id": "key-history-scope-a",
"original_request_body": {
"model": "deepseek-v4-flash",
"input": [{"role": "user", "content": "private request"}]
}
});
record_converted_response_history(
&report_context,
&json!({
"id": "resp_history_scope_test_1",
"status": "completed",
"output": [{"type": "message", "role": "assistant", "content": []}]
}),
);
let continuation = json!({
"model": "deepseek-v4-flash",
"previous_response_id": "resp_history_scope_test_1",
"input": "continue"
});
let owner_result = convert_request(
"openai:responses",
"openai:chat",
&continuation,
&FormatContext::default().with_history_scope("key-history-scope-a"),
);
assert!(owner_result.is_ok());
let other_user_error = convert_request(
"openai:responses",
"openai:chat",
&continuation,
&FormatContext::default().with_history_scope("key-history-scope-b"),
)
.expect_err("another API key must not recover scoped history");
assert!(matches!(
other_user_error,
crate::formats::context::FormatError::UnsupportedField { ref field, .. }
if field == "previous_response_id"
));
}
#[test]
fn converts_all_forty_two_tools_and_forces_late_tools() {
let tools = (0..42)
.map(|index| {
json!({
"type": "function",
"name": format!("security_tool_{index:02}"),
"description": format!("Security tool {index}"),
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]
}
})
})
.collect::<Vec<_>>();
for forced_index in [18usize, 41usize] {
let body = json!({
"model": "deepseek-v4-flash",
"input": [{"role": "user", "content": "run the selected scan"}],
"tools": tools,
"tool_choice": {
"type": "function",
"name": format!("security_tool_{forced_index:02}")
}
});
let converted = convert_request(
"openai:responses",
"openai:chat",
&body,
&FormatContext::default(),
)
.expect("all Responses tools should convert to Chat");
let converted_tools = converted["tools"]
.as_array()
.expect("chat tools should be an array");
assert_eq!(converted_tools.len(), 42);
for (index, tool) in converted_tools.iter().enumerate() {
assert_eq!(
tool["function"]["name"],
json!(format!("security_tool_{index:02}"))
);
}
assert_eq!(
converted["tool_choice"]["function"]["name"],
json!(format!("security_tool_{forced_index:02}"))
);
}
}
#[test]
fn restores_two_consecutive_tool_call_rounds() {
let first_request = json!({
"model": "deepseek-v4-flash",
"input": [{"role": "user", "content": "perform a deep scan"}],
"parallel_tool_calls": true
});
record_converted_response_history(
&conversion_report_context(first_request.clone()),
&json!({
"id": "resp_history_multiturn_test_1",
"status": "completed",
"output": [{
"type": "function_call",
"id": "fc_history_multiturn_test_1",
"call_id": "call_discovery_manifest_1",
"name": "create_discovery_manifest",
"arguments": "{\"root\":\"src\"}"
}]
}),
);
let second_request = json!({
"model": "deepseek-v4-flash",
"previous_response_id": "resp_history_multiturn_test_1",
"input": [{
"type": "function_call_output",
"call_id": "call_discovery_manifest_1",
"output": "manifest-1"
}],
"parallel_tool_calls": true
});
let second_chat = convert_request(
"openai:responses",
"openai:chat",
&second_request,
&FormatContext::default(),
)
.expect("first continuation should convert");
assert_eq!(second_chat["messages"][1]["role"], "assistant");
assert_eq!(
second_chat["messages"][1]["tool_calls"][0]["id"],
"call_discovery_manifest_1"
);
assert_eq!(second_chat["messages"][2]["role"], "tool");
assert_eq!(
second_chat["messages"][2]["tool_call_id"],
"call_discovery_manifest_1"
);
record_converted_response_history(
&conversion_report_context(second_request.clone()),
&json!({
"id": "resp_history_multiturn_test_2",
"status": "completed",
"output": [
{
"type": "function_call",
"id": "fc_history_multiturn_test_2a",
"call_id": "call_deep_scan_2a",
"name": "deep_scan",
"arguments": "{\"manifest\":\"manifest-1\"}"
},
{
"type": "function_call",
"id": "fc_history_multiturn_test_2b",
"call_id": "call_audit_2b",
"name": "audit_results",
"arguments": "{\"manifest\":\"manifest-1\"}"
}
]
}),
);
let third_chat = convert_request(
"openai:responses",
"openai:chat",
&json!({
"model": "deepseek-v4-flash",
"previous_response_id": "resp_history_multiturn_test_2",
"input": [
{
"type": "function_call_output",
"call_id": "call_deep_scan_2a",
"output": "scan-complete"
},
{
"type": "function_call_output",
"call_id": "call_audit_2b",
"output": "audit-complete"
}
]
}),
&FormatContext::default(),
)
.expect("second continuation should convert");
let messages = third_chat["messages"]
.as_array()
.expect("chat messages should be an array");
assert_eq!(messages.len(), 6);
assert_eq!(messages[3]["role"], "assistant");
assert_eq!(messages[3]["tool_calls"].as_array().map(Vec::len), Some(2));
assert_eq!(messages[3]["tool_calls"][0]["id"], "call_deep_scan_2a");
assert_eq!(messages[3]["tool_calls"][1]["id"], "call_audit_2b");
assert_eq!(messages[4]["tool_call_id"], "call_deep_scan_2a");
assert_eq!(messages[5]["tool_call_id"], "call_audit_2b");
}
}
@@ -1,6 +1,7 @@
use serde_json::Value;
pub mod codex;
pub(crate) mod history;
pub mod request;
pub mod response;
pub mod spec;
@@ -5,7 +5,7 @@ use std::{
use serde_json::{json, Map, Value};
use super::encode_tool_result_error;
use super::{encode_tool_result_error, history::record_converted_response_history};
use crate::{
formats::context::FormatContext,
@@ -28,7 +28,10 @@ pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
}
pub fn to(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
Some(to_raw(response, &ctx.report_context_value(), false))
let report_context = ctx.report_context_value();
let response = to_raw(response, &report_context, false);
record_converted_response_history(&report_context, &response);
Some(response)
}
pub fn to_compact(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
@@ -135,6 +135,22 @@ pub fn convert_request(
) -> Result<Value, FormatError> {
let source = parse_format(source_format)?;
let target = parse_format(target_format)?;
let expanded_body = if source == FormatId::OpenAiResponses && target == FormatId::OpenAiChat {
Some(
openai_responses::history::expand_previous_response_for_chat(
body,
ctx.history_scope.as_deref(),
)
.map_err(|reason| FormatError::UnsupportedField {
format: source.as_str().to_string(),
field: "previous_response_id".to_string(),
reason,
})?,
)
} else {
None
};
let body = expanded_body.as_ref().unwrap_or(body);
validate_openai_responses_target_contract(target_format, body)?;
let mut request = parse_request(source_format, body, ctx)?;
validate_runtime_request_conversion(
@@ -3181,7 +3197,7 @@ mod tests {
use super::{
convert_request, convert_request_pure, convert_request_pure_with_context,
convert_response_pure, FormatContext,
convert_response_pure, FormatContext, FormatError,
};
use crate::formats::id::FormatId;
@@ -5487,7 +5503,7 @@ mod tests {
}
#[test]
fn legacy_openai_responses_to_chat_does_not_leak_responses_only_extensions() {
fn runtime_openai_responses_to_chat_rejects_missing_previous_response_history() {
let body = json!({
"model": "gpt-source",
"input": [{"role": "user", "content": "hello"}],
@@ -5496,17 +5512,19 @@ mod tests {
"stream": true
});
let converted = convert_request(
let error = convert_request(
"openai:responses",
"openai:chat",
&body,
&FormatContext::default(),
)
.expect("legacy conversion should still emit a chat body");
.expect_err("missing previous response history must fail closed");
assert!(converted.get("stream").is_none());
assert!(converted.get("include").is_none());
assert!(converted.get("previous_response_id").is_none());
assert!(matches!(
error,
FormatError::UnsupportedField { ref field, .. }
if field == "previous_response_id"
));
}
#[test]
@@ -90,10 +90,13 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
request_headers: Option<&http::HeaderMap>,
enable_model_directives: bool,
) -> Option<Value> {
let format_context = FormatContext::default()
let mut format_context = FormatContext::default()
.with_mapped_model(mapped_model)
.with_request_path(request_path)
.with_upstream_stream(upstream_is_stream);
if let Some(history_scope) = user_api_key_id {
format_context = format_context.with_history_scope(history_scope);
}
let source_api_format = compatible_source_format_for_standard_request(
body_json,
client_api_format,
@@ -340,6 +343,8 @@ fn normalize_standard_request_to_openai_chat_request_cow<'a>(
#[cfg(test)]
mod tests {
use crate::formats::openai::responses::history::record_converted_response_history;
use super::{
build_standard_request_body, build_standard_request_body_from_canonical,
build_standard_request_body_with_model_directives,
@@ -491,6 +496,76 @@ mod tests {
}
}
#[test]
fn standard_request_body_scopes_previous_response_history_by_api_key() {
record_converted_response_history(
&json!({
"needs_conversion": true,
"client_api_format": "openai:responses",
"provider_api_format": "openai:chat",
"api_key_id": "standard-history-key-a",
"original_request_body": {
"model": "source-model",
"input": [{"role": "user", "content": "inspect the repository"}]
}
}),
&json!({
"id": "resp_standard_history_scope_1",
"status": "completed",
"output": [{
"type": "function_call",
"id": "fc_standard_history_scope_1",
"call_id": "call_standard_history_scope_1",
"name": "inspect_repository",
"arguments": "{}"
}]
}),
);
let continuation = json!({
"model": "source-model",
"previous_response_id": "resp_standard_history_scope_1",
"input": [{
"type": "function_call_output",
"call_id": "call_standard_history_scope_1",
"output": "inspection-complete"
}]
});
let owner = build_standard_request_body(
&continuation,
"openai:responses",
"mapped-model",
"custom",
"openai:chat",
"/v1/responses",
false,
None,
Some("standard-history-key-a"),
)
.expect("the owning API key should restore response history");
assert_eq!(
owner["messages"][1]["tool_calls"][0]["id"],
"call_standard_history_scope_1"
);
assert_eq!(
owner["messages"][2]["tool_call_id"],
"call_standard_history_scope_1"
);
assert!(build_standard_request_body(
&continuation,
"openai:responses",
"mapped-model",
"custom",
"openai:chat",
"/v1/responses",
false,
None,
Some("standard-history-key-b"),
)
.is_none());
}
#[test]
fn standard_request_body_stream_policy_wins_after_body_rules() {
let request = json!({
@@ -6,6 +6,7 @@ use aether_ai_formats::formats::conversion::request::{
normalize_claude_request_to_openai_chat_request,
normalize_gemini_request_to_openai_chat_request,
normalize_openai_responses_request_to_openai_chat_request,
normalize_openai_responses_request_to_openai_chat_request_with_history_scope,
};
use aether_ai_formats::{request_conversion_kind, FormatContext, RequestConversionKind};
use serde_json::{json, Value};
@@ -67,11 +68,16 @@ fn chat_compatible_body_for_openai_chat_endpoint(body_json: &Value) -> Option<Co
fn chat_compatible_body_for_standard_source<'a>(
body_json: &'a Value,
client_api_format: &str,
history_scope: Option<&str>,
) -> Option<Cow<'a, Value>> {
match aether_ai_formats::normalize_api_format_alias(client_api_format).as_str() {
"openai:chat" => chat_compatible_body_for_openai_chat_endpoint(body_json),
"openai:responses" | "openai:responses:compact" => {
normalize_openai_responses_request_to_openai_chat_request(body_json).map(Cow::Owned)
normalize_openai_responses_request_to_openai_chat_request_with_history_scope(
body_json,
history_scope,
)
.map(Cow::Owned)
}
"claude:messages" => {
normalize_claude_request_to_openai_chat_request(body_json).map(Cow::Owned)
@@ -325,7 +331,28 @@ pub fn build_cross_format_openai_responses_request_body_with_model_directives(
upstream_is_stream: bool,
enable_model_directives: bool,
) -> Option<Value> {
let chat_like_request = chat_compatible_body_for_standard_source(body_json, client_api_format)?;
build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope(
body_json,
mapped_model,
client_api_format,
provider_api_format,
upstream_is_stream,
enable_model_directives,
None,
)
}
pub fn build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope(
body_json: &Value,
mapped_model: &str,
client_api_format: &str,
provider_api_format: &str,
upstream_is_stream: bool,
enable_model_directives: bool,
history_scope: Option<&str>,
) -> Option<Value> {
let chat_like_request =
chat_compatible_body_for_standard_source(body_json, client_api_format, history_scope)?;
let conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
let provider_request_body = match conversion_kind {
RequestConversionKind::ToOpenAIChat => {
@@ -9,6 +9,9 @@ use crate::formats::openai::chat::stream::{
OpenAIResponsesProviderState,
};
use crate::formats::openai::image::stream::OpenAiImageStreamTerminalState;
use crate::formats::openai::responses::history::{
record_converted_response_history, ResponseHistoryRecord,
};
use crate::formats::shared::error_body::{
build_core_error_body_for_client_format, LocalCoreSyncErrorKind,
};
@@ -25,6 +28,8 @@ pub struct StreamingStandardFormatMatrix {
client: Option<ClientStreamEmitter>,
propagated_actual_service_tier: Option<String>,
terminated: bool,
history_recorded: bool,
pending_history_record: Option<ResponseHistoryRecord>,
}
impl StreamingStandardFormatMatrix {
@@ -56,7 +61,7 @@ impl StreamingStandardFormatMatrix {
client.set_actual_service_tier(propagated_actual_service_tier.as_deref());
}
}
self.emit_frames(frames)
self.emit_frames(report_context, frames)
}
pub fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
@@ -79,10 +84,11 @@ impl StreamingStandardFormatMatrix {
client.set_actual_service_tier(propagated_actual_service_tier.as_deref());
}
}
let mut out = self.emit_frames(frames)?;
let mut out = self.emit_frames(report_context, frames)?;
if let Some(client) = self.client.as_mut() {
out.extend(client.finish()?);
}
self.record_response_history(report_context);
Ok(out)
}
@@ -100,6 +106,7 @@ impl StreamingStandardFormatMatrix {
fn emit_frames(
&mut self,
report_context: &Value,
frames: Vec<CanonicalStreamFrame>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let Some(client) = self.client.as_mut() else {
@@ -133,9 +140,30 @@ impl StreamingStandardFormatMatrix {
}
out.extend(client.emit(frame)?);
}
self.record_response_history(report_context);
Ok(out)
}
fn record_response_history(&mut self, report_context: &Value) {
if self.history_recorded {
return;
}
let Some(response) = self.client.as_ref().and_then(|client| match client {
ClientStreamEmitter::OpenAIResponses(emitter) => {
emitter.completed_response_for_history()
}
_ => None,
}) else {
return;
};
self.pending_history_record = record_converted_response_history(report_context, response);
self.history_recorded = true;
}
pub fn take_response_history_record(&mut self) -> Option<ResponseHistoryRecord> {
self.pending_history_record.take()
}
fn emit_error(&mut self, error_body: Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let Some(client) = self.client.as_mut() else {
return Ok(Vec::new());
@@ -619,6 +647,7 @@ fn parse_gemini_error(payload: &Value) -> Option<(String, Option<String>, LocalC
#[cfg(test)]
mod tests {
use super::{StreamingStandardFormatMatrix, StreamingStandardTerminalObserver};
use crate::formats::{context::FormatContext, registry::convert_request};
use serde_json::{json, Value};
fn report_context(provider_api_format: &str, client_api_format: &str) -> Value {
@@ -642,6 +671,121 @@ mod tests {
.collect()
}
#[test]
fn streamed_chat_tool_call_records_responses_continuation_history() {
let report_context = json!({
"provider_api_format": "openai:chat",
"client_api_format": "openai:responses",
"mapped_model": "deepseek-v4-flash",
"needs_conversion": true,
"original_request_body": {
"model": "deepseek-v4-flash",
"input": [{"role": "user", "content": "perform a deep scan"}]
}
});
let mut matrix = StreamingStandardFormatMatrix::default();
matrix
.transform_line(
&report_context,
data_line(json!({
"id": "chatcmpl_history_stream_test_1",
"model": "deepseek-v4-flash",
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": 0,
"id": "call_history_stream_test_1",
"type": "function",
"function": {
"name": "create_discovery_manifest",
"arguments": "{\"root\":"
}
}]
},
"finish_reason": Value::Null
}]
})),
)
.expect("tool start should convert");
matrix
.transform_line(
&report_context,
data_line(json!({
"id": "chatcmpl_history_stream_test_1",
"model": "deepseek-v4-flash",
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": 0,
"function": {"arguments": "\"src\"}"}
}]
},
"finish_reason": Value::Null
}]
})),
)
.expect("tool arguments should convert");
let terminal = matrix
.transform_line(
&report_context,
data_line(json!({
"id": "chatcmpl_history_stream_test_1",
"model": "deepseek-v4-flash",
"choices": [{
"index": 0,
"delta": {},
"finish_reason": "tool_calls"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 4,
"total_tokens": 14
}
})),
)
.expect("tool finish should convert");
let terminal_sse = String::from_utf8(terminal).expect("SSE should be utf8");
assert!(terminal_sse.contains("event: response.function_call_arguments.done"));
assert!(terminal_sse.contains("event: response.output_item.done"));
assert!(terminal_sse.contains("event: response.completed"));
let persisted = matrix
.take_response_history_record()
.expect("completed stream should expose one persistence record");
assert!(persisted
.storage_key
.starts_with("ai:responses:history:v1:"));
assert!(persisted.payload.contains("resp_history_stream_test_1"));
assert!(matrix.take_response_history_record().is_none());
let continuation = convert_request(
"openai:responses",
"openai:chat",
&json!({
"model": "deepseek-v4-flash",
"previous_response_id": "resp_history_stream_test_1",
"input": [{
"type": "function_call_output",
"call_id": "call_history_stream_test_1",
"output": "manifest-created"
}]
}),
&FormatContext::default(),
)
.expect("streamed response history should restore the next Chat request");
assert_eq!(continuation["messages"][1]["role"], "assistant");
assert_eq!(
continuation["messages"][1]["tool_calls"][0]["id"],
"call_history_stream_test_1"
);
assert_eq!(continuation["messages"][2]["role"], "tool");
assert_eq!(
continuation["messages"][2]["tool_call_id"],
"call_history_stream_test_1"
);
}
#[test]
fn transforms_provider_errors_to_openai_chat_error_bodies() {
let cases = [
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use crate::formats::openai::image::stream::{OpenAiImageChatStreamState, OpenAiImageStreamState};
use crate::formats::openai::responses::history::ResponseHistoryRecord;
use crate::formats::openai::responses::response::ensure_modern_openai_responses_response_fields;
use crate::formats::shared::model_directives::model_directive_display_model_from_report_context;
use crate::formats::shared::response::{
@@ -345,6 +346,16 @@ impl AiSurfaceStreamRewriter<'_> {
}
}
pub fn take_response_history_record(&mut self) -> Option<ResponseHistoryRecord> {
match &mut self.state {
AiSurfaceStreamRewriteState::Standard(state) => state.take_response_history_record(),
AiSurfaceStreamRewriteState::KiroToClaudeCliThenStandard { standard, .. } => {
standard.take_response_history_record()
}
_ => None,
}
}
fn transform_line(&mut self, line: Vec<u8>) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match &mut self.state {
AiSurfaceStreamRewriteState::EnvelopeUnwrap => {
@@ -12,6 +12,9 @@ use crate::formats::gemini::generate_content::stream::GeminiClientEmitter;
use crate::formats::openai::chat::stream::{
OpenAIChatClientEmitter, OpenAIResponsesClientEmitter, OpenAIResponsesProviderState,
};
use crate::formats::openai::responses::history::{
record_converted_response_history, ResponseHistoryRecord,
};
use crate::formats::shared::sse::{encode_done_sse, encode_json_sse};
use crate::formats::shared::stream_core::common::{
build_openai_chat_chunk, build_openai_chat_finish_chunk,
@@ -27,6 +30,7 @@ use crate::formats::shared::AiSurfaceFinalizeError;
pub struct SyncToStreamBridgeOutcome {
pub sse_body: Vec<u8>,
pub terminal_summary: Option<ExecutionStreamTerminalSummary>,
pub response_history_record: Option<ResponseHistoryRecord>,
}
pub fn maybe_bridge_standard_sync_json_to_stream(
@@ -101,6 +105,8 @@ pub fn maybe_bridge_standard_sync_json_to_stream(
&openai_responses_response,
provider_actual_service_tier.clone(),
);
let response_history_record =
record_converted_response_history(&bridge_context, &openai_responses_response);
let canonical_frames = build_canonical_frames_from_openai_responses_response(
&openai_responses_response,
&bridge_context,
@@ -124,6 +130,7 @@ pub fn maybe_bridge_standard_sync_json_to_stream(
Ok(Some(SyncToStreamBridgeOutcome {
sse_body,
terminal_summary,
response_history_record,
}))
}
@@ -168,6 +175,7 @@ fn bridge_openai_responses_same_family_sync_json_to_stream(
response,
provider_actual_service_tier_from_sync_response(response, provider_api_format),
),
response_history_record: None,
}))
}
@@ -206,6 +214,7 @@ fn maybe_bridge_openai_image_sync_json_to_stream(
report_context,
image_count,
)),
response_history_record: None,
}))
}
@@ -277,6 +286,7 @@ fn maybe_bridge_openai_image_sync_json_to_chat_stream(
Ok(Some(SyncToStreamBridgeOutcome {
sse_body,
terminal_summary: Some(summary),
response_history_record: None,
}))
}
@@ -344,6 +354,7 @@ fn maybe_bridge_openai_image_sync_json_to_responses_stream(
report_context,
image_count,
)),
response_history_record: None,
}))
}
@@ -769,6 +780,7 @@ fn maybe_bridge_aether_sse_response_capture_to_stream(
Ok(Some(SyncToStreamBridgeOutcome {
sse_body,
terminal_summary,
response_history_record: None,
}))
}
@@ -1329,6 +1341,45 @@ mod tests {
);
}
#[test]
fn chat_sync_bridge_exposes_responses_history_record() {
let report_context = json!({
"provider_api_format": "openai:chat",
"client_api_format": "openai:responses",
"needs_conversion": true,
"api_key_id": "sync-bridge-history-key",
"original_request_body": {
"model": "deepseek-v4-flash",
"input": [{"role": "user", "content": "inspect the repository"}]
}
});
let outcome = maybe_bridge_standard_sync_json_to_stream(
&json!({
"id": "chatcmpl_sync_history_1",
"object": "chat.completion",
"model": "deepseek-v4-flash",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "done"},
"finish_reason": "stop"
}]
}),
"openai:chat",
"openai:responses",
Some(&report_context),
)
.expect("bridge should succeed")
.expect("bridge should produce sse");
let history_record = outcome
.response_history_record
.expect("completed bridge should expose shared history");
assert!(history_record
.storage_key
.starts_with("ai:responses:history:v1:"));
assert!(history_record.payload.contains("resp_sync_history_1"));
}
#[test]
fn openai_sync_usage_derives_missing_input_tokens_from_total() {
let usage = standardized_usage_from_openai_usage(&json!({