fix(responses): replay DeepSeek opaque reasoning state

This commit is contained in:
ZheFox
2026-08-20 00:40:48 +08:00
parent d21d8ce9f5
commit bef282cfee
22 changed files with 516 additions and 51 deletions
+3
View File
@@ -72,12 +72,15 @@ pub use crate::formats::openai::{
request_contract::{
finalize_openai_provider_request,
finalize_openai_provider_request_with_codex_model_capabilities,
finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy,
validate_openai_provider_request_contract, OpenAiProviderRequestContractViolation,
OpenAiProviderRequestFinalization,
},
responses::{
openai_responses_synthetic_reasoning_item_id,
strip_incompatible_openai_responses_reasoning_items,
strip_incompatible_openai_responses_reasoning_items_with_policy,
OpenAiResponsesReasoningReplayPolicy,
},
};
pub use crate::formats::shared::error_body::{
@@ -27,13 +27,32 @@ pub fn finalize_openai_provider_request(
body: &mut Value,
finalization: OpenAiProviderRequestFinalization<'_>,
) -> Result<(), OpenAiProviderRequestContractViolation> {
finalize_openai_provider_request_with_codex_model_capabilities(body, finalization, None)
finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy(
body,
finalization,
None,
super::responses::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
)
}
pub fn finalize_openai_provider_request_with_codex_model_capabilities(
body: &mut Value,
finalization: OpenAiProviderRequestFinalization<'_>,
model_capabilities: Option<&super::responses::codex::CodexResponsesModelCapabilities>,
) -> Result<(), OpenAiProviderRequestContractViolation> {
finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy(
body,
finalization,
model_capabilities,
super::responses::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
)
}
pub fn finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy(
body: &mut Value,
finalization: OpenAiProviderRequestFinalization<'_>,
model_capabilities: Option<&super::responses::codex::CodexResponsesModelCapabilities>,
reasoning_replay_policy: super::responses::OpenAiResponsesReasoningReplayPolicy,
) -> Result<(), OpenAiProviderRequestContractViolation> {
let is_codex_reasoning_endpoint = finalization
.provider_type
@@ -80,9 +99,10 @@ pub fn finalize_openai_provider_request_with_codex_model_capabilities(
body,
finalization.provider_api_format,
);
super::responses::strip_incompatible_openai_responses_reasoning_items(
super::responses::strip_incompatible_openai_responses_reasoning_items_with_policy(
body,
finalization.provider_api_format,
reasoning_replay_policy,
);
crate::enforce_request_body_stream_field(
body,
@@ -10,6 +10,18 @@ pub mod stream;
const TOOL_ERROR_PREFIX: &str = "[tool error]";
const AETHER_REASONING_ITEM_ID_PREFIX: &str = "rs_aether_";
/// Controls which provider-owned reasoning items may be replayed on a Responses request.
///
/// OpenAI reasoning references are identified by their `rs...` item IDs. DeepSeek's Responses
/// contract instead returns opaque, id-less `reasoning_text` items whose `encrypted_content`
/// must be sent back unchanged on later tool turns.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum OpenAiResponsesReasoningReplayPolicy {
#[default]
OpenAiItemIds,
DeepSeekOpaque,
}
/// Builds a stable, wire-compatible ID for a reasoning item synthesized by Aether.
///
/// The marker lets the outbound request sanitizer distinguish synthetic summaries from
@@ -35,6 +47,18 @@ pub fn openai_responses_synthetic_reasoning_item_id(
pub fn strip_incompatible_openai_responses_reasoning_items(
body: &mut Value,
provider_api_format: &str,
) -> usize {
strip_incompatible_openai_responses_reasoning_items_with_policy(
body,
provider_api_format,
OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
)
}
pub fn strip_incompatible_openai_responses_reasoning_items_with_policy(
body: &mut Value,
provider_api_format: &str,
policy: OpenAiResponsesReasoningReplayPolicy,
) -> usize {
if !aether_ai_formats::is_openai_responses_family_format(provider_api_format) {
return 0;
@@ -43,17 +67,25 @@ pub fn strip_incompatible_openai_responses_reasoning_items(
return 0;
};
let original_len = items.len();
items.retain(openai_responses_reasoning_item_is_replayable);
items.retain(|item| openai_responses_reasoning_item_is_replayable(item, policy));
original_len.saturating_sub(items.len())
}
fn openai_responses_reasoning_item_is_replayable(item: &Value) -> bool {
fn openai_responses_reasoning_item_is_replayable(
item: &Value,
policy: OpenAiResponsesReasoningReplayPolicy,
) -> bool {
let Some(object) = item.as_object() else {
return true;
};
if object.get("type").and_then(Value::as_str) != Some("reasoning") {
return true;
}
if policy == OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
&& deepseek_opaque_reasoning_item_is_replayable(object)
{
return true;
}
let Some(id) = object
.get("id")
.and_then(Value::as_str)
@@ -71,6 +103,33 @@ fn openai_responses_reasoning_item_is_replayable(item: &Value) -> bool {
.is_some_and(|encrypted_content| !encrypted_content.trim().is_empty())
}
fn deepseek_opaque_reasoning_item_is_replayable(object: &serde_json::Map<String, Value>) -> bool {
if let Some(id) = object.get("id") {
let id_is_empty = id.is_null() || id.as_str().is_some_and(|value| value.trim().is_empty());
if !id_is_empty {
return false;
}
}
let has_encrypted_content = object
.get("encrypted_content")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty());
let has_reasoning_text =
object
.get("content")
.and_then(Value::as_array)
.is_some_and(|content| {
content.iter().any(|part| {
part.get("type").and_then(Value::as_str) == Some("reasoning_text")
&& part
.get("text")
.and_then(Value::as_str)
.is_some_and(|text| !text.is_empty())
})
});
has_encrypted_content && has_reasoning_text
}
/// Semantic operation carried by an OpenAI Responses request that asks the
/// service to compact a thread. The request still uses the Responses wire
/// contract and transport endpoint.
@@ -121,7 +180,9 @@ mod tests {
use super::{
openai_responses_request_operation, openai_responses_synthetic_reasoning_item_id,
strip_incompatible_openai_responses_reasoning_items, OPENAI_RESPONSES_OPERATION_COMPACT,
strip_incompatible_openai_responses_reasoning_items,
strip_incompatible_openai_responses_reasoning_items_with_policy,
OpenAiResponsesReasoningReplayPolicy, OPENAI_RESPONSES_OPERATION_COMPACT,
};
#[test]
@@ -210,4 +271,85 @@ mod tests {
);
assert_eq!(body["input"].as_array().map(Vec::len), Some(1));
}
#[test]
fn deepseek_policy_preserves_idless_opaque_reasoning_text_only_for_deepseek() {
let item = json!({
"type": "reasoning",
"encrypted_content": "550e8400-e29b-41d4-a716-446655440000",
"content": [{
"type": "reasoning_text",
"text": "opaque provider reasoning that must be replayed"
}]
});
let mut strict = json!({"input": [item.clone()]});
let mut deepseek = json!({"input": [item]});
assert_eq!(
strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut strict,
"openai:responses",
OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
),
1
);
assert_eq!(
strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut deepseek,
"openai:responses",
OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque,
),
0
);
assert_eq!(deepseek["input"].as_array().map(Vec::len), Some(1));
}
#[test]
fn deepseek_policy_does_not_preserve_unbound_reasoning_summaries() {
let mut body = json!({
"input": [
{
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": "missing state"}]
},
{
"type": "reasoning",
"encrypted_content": "opaque-without-reasoning-text",
"summary": [{"type": "summary_text", "text": "summary"}]
}
]
});
assert_eq!(
strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut body,
"openai:responses",
OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque,
),
2
);
assert_eq!(body["input"].as_array().map(Vec::len), Some(0));
}
#[test]
fn deepseek_policy_does_not_preserve_opaque_item_with_foreign_id() {
let mut body = json!({
"input": [{
"type": "reasoning",
"id": "item_provider_owned",
"encrypted_content": "opaque-state",
"content": [{"type": "reasoning_text", "text": "thinking"}]
}]
});
assert_eq!(
strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut body,
"openai:responses",
OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque,
),
1
);
assert_eq!(body["input"].as_array().map(Vec::len), Some(0));
}
}
@@ -73,6 +73,7 @@ pub fn build_standard_request_body_with_model_directives(
user_api_key_id,
None,
enable_model_directives,
crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
)
}
@@ -89,6 +90,7 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
user_api_key_id: Option<&str>,
request_headers: Option<&http::HeaderMap>,
enable_model_directives: bool,
reasoning_replay_policy: crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy,
) -> Option<Value> {
let mut format_context = FormatContext::default()
.with_mapped_model(mapped_model)
@@ -102,13 +104,26 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
client_api_format,
provider_api_format,
);
let mut provider_request_body = convert_request(
source_api_format.as_ref(),
provider_api_format,
body_json,
&format_context,
)
.ok()?;
// A same-family Responses hop is a wire-preserving route. Parsing through the
// canonical request model here would intentionally discard provider-owned
// input item fields (for example DeepSeek's id-less `reasoning_text` and
// future opaque capability fields), even though no format conversion is
// required. Keep the original object and only rewrite the routing model;
// the normal provider-contract and compatibility passes below still apply.
let mut provider_request_body =
if is_same_openai_responses_family(source_api_format.as_ref(), provider_api_format) {
let mut object = body_json.as_object()?.clone();
object.insert("model".to_string(), Value::String(mapped_model.to_string()));
Value::Object(object)
} else {
convert_request(
source_api_format.as_ref(),
provider_api_format,
body_json,
&format_context,
)
.ok()?
};
if enable_model_directives {
apply_model_directive_overrides_from_request(
@@ -153,9 +168,10 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
&mut provider_request_body,
provider_api_format,
);
crate::formats::openai::responses::strip_incompatible_openai_responses_reasoning_items(
crate::formats::openai::responses::strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut provider_request_body,
provider_api_format,
reasoning_replay_policy,
);
strip_openai_responses_input_content_cache_control(
&mut provider_request_body,
@@ -176,6 +192,16 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
Some(provider_request_body)
}
fn is_same_openai_responses_family(source_api_format: &str, provider_api_format: &str) -> bool {
matches!(
aether_ai_formats::normalize_api_format_alias(source_api_format).as_str(),
"openai:responses" | "openai:responses:compact"
) && matches!(
aether_ai_formats::normalize_api_format_alias(provider_api_format).as_str(),
"openai:responses" | "openai:responses:compact"
)
}
fn compatible_source_format_for_standard_request<'a>(
body_json: &Value,
client_api_format: &'a str,
@@ -352,8 +378,10 @@ mod tests {
use super::{
build_standard_request_body, build_standard_request_body_from_canonical,
build_standard_request_body_with_model_directives,
build_standard_request_body_with_model_directives_and_request_headers,
normalize_standard_request_to_openai_chat_request,
};
use crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy;
use serde_json::{json, Value};
const STANDARD_SURFACES: &[&str] = &[
@@ -500,6 +528,84 @@ mod tests {
}
}
#[test]
fn same_responses_family_matrix_preserves_opaque_reasoning_items_and_unknown_fields() {
let input = (0..66)
.map(|index| {
json!({
"type": "reasoning",
"encrypted_content": format!("opaque-state-{index}"),
"content": [{
"type": "reasoning_text",
"text": format!("thinking {index}")
}],
"future_capability": {"budget_class": "dynamic"}
})
})
.collect::<Vec<_>>();
let request = json!({
"model": "deepseek-v4-flash",
"input": input,
"future_request_field": {"preserve": true}
});
let preserved = build_standard_request_body_with_model_directives_and_request_headers(
&request,
"openai:responses",
"deepseek-v4-flash",
"custom",
"openai:responses",
"/v1/responses",
false,
None,
None,
None,
false,
OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque,
)
.expect("same-family Responses body should build");
assert_eq!(preserved["model"], "deepseek-v4-flash");
assert_eq!(preserved["future_request_field"]["preserve"], true);
assert_eq!(
preserved["input"]
.as_array()
.expect("input array")
.iter()
.filter(|item| item["type"] == "reasoning")
.count(),
66
);
assert_eq!(
preserved["input"][0]["future_capability"]["budget_class"],
"dynamic"
);
let strict = build_standard_request_body_with_model_directives_and_request_headers(
&request,
"openai:responses",
"deepseek-v4-flash",
"openai",
"openai:responses",
"/v1/responses",
false,
None,
None,
None,
false,
OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
)
.expect("strict same-family Responses body should build");
assert_eq!(
strict["input"]
.as_array()
.expect("strict input array")
.iter()
.filter(|item| item["type"] == "reasoning")
.count(),
0
);
}
#[test]
fn standard_request_body_scopes_previous_response_history_by_api_key() {
record_converted_response_history(
@@ -303,10 +303,6 @@ pub fn build_local_openai_responses_request_body_with_model_directives(
require_streaming,
require_body_stream_field,
);
crate::formats::openai::responses::strip_incompatible_openai_responses_reasoning_items(
&mut provider_request_body,
"openai:responses",
);
Some(provider_request_body)
}
+4 -1
View File
@@ -39,6 +39,7 @@ pub use formats::openai::reasoning::{
pub use formats::openai::request_contract::{
finalize_openai_provider_request,
finalize_openai_provider_request_with_codex_model_capabilities,
finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy,
validate_openai_provider_request_contract, OpenAiProviderRequestContractViolation,
OpenAiProviderRequestFinalization,
};
@@ -54,7 +55,9 @@ pub use formats::openai::responses::request::{
};
pub use formats::openai::responses::{
openai_responses_request_operation, openai_responses_synthetic_reasoning_item_id,
strip_incompatible_openai_responses_reasoning_items, OPENAI_RESPONSES_OPERATION_COMPACT,
strip_incompatible_openai_responses_reasoning_items,
strip_incompatible_openai_responses_reasoning_items_with_policy,
OpenAiResponsesReasoningReplayPolicy, OPENAI_RESPONSES_OPERATION_COMPACT,
};
pub use formats::registry::{
build_stream_transcoder, convert_request, convert_request_pure,
@@ -145,6 +145,7 @@ pub use rules::{
pub use same_format_provider::{
build_same_format_provider_headers, build_same_format_provider_request_body,
build_same_format_provider_request_body_with_compatibility_report,
build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy,
build_same_format_provider_upstream_url, classify_same_format_provider_request_behavior,
classify_same_format_provider_request_behavior_for_operation,
enforce_same_format_provider_api_operation_body_policy,
@@ -207,15 +207,32 @@ pub fn classify_same_format_provider_request_behavior_for_operation(
pub fn build_same_format_provider_request_body(
input: SameFormatProviderRequestBodyInput<'_>,
) -> Option<Value> {
build_same_format_provider_request_body_inner(input, None)
build_same_format_provider_request_body_inner(
input,
None,
aether_ai_formats::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
)
}
pub fn build_same_format_provider_request_body_with_compatibility_report(
input: SameFormatProviderRequestBodyInput<'_>,
) -> Option<SameFormatProviderRequestBodyOutput> {
build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy(
input,
aether_ai_formats::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
)
}
pub fn build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy(
input: SameFormatProviderRequestBodyInput<'_>,
reasoning_replay_policy: aether_ai_formats::OpenAiResponsesReasoningReplayPolicy,
) -> Option<SameFormatProviderRequestBodyOutput> {
let mut compatibility_edits = Vec::new();
let body =
build_same_format_provider_request_body_inner(input, Some(&mut compatibility_edits))?;
let body = build_same_format_provider_request_body_inner(
input,
Some(&mut compatibility_edits),
reasoning_replay_policy,
)?;
Some(SameFormatProviderRequestBodyOutput {
body,
compatibility_edits,
@@ -239,6 +256,7 @@ pub fn enforce_same_format_provider_api_operation_body_policy(
fn build_same_format_provider_request_body_inner(
input: SameFormatProviderRequestBodyInput<'_>,
mut compatibility_edits: Option<&mut Vec<SameFormatProviderCompatibilityEdit>>,
reasoning_replay_policy: aether_ai_formats::OpenAiResponsesReasoningReplayPolicy,
) -> Option<Value> {
if let Some(kiro_auth_config) = input.kiro_auth_config {
let body = build_kiro_provider_request_body(
@@ -437,9 +455,10 @@ fn build_same_format_provider_request_body_inner(
);
}
let stripped_reasoning_items =
aether_ai_formats::strip_incompatible_openai_responses_reasoning_items(
aether_ai_formats::strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut provider_request_body,
input.provider_api_format,
reasoning_replay_policy,
);
if stripped_reasoning_items > 0 {
record_compatibility_edit(
@@ -466,7 +485,7 @@ fn build_same_format_provider_request_body_inner(
) {
return None;
}
if aether_ai_formats::finalize_openai_provider_request(
if aether_ai_formats::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy(
&mut provider_request_body,
aether_ai_formats::OpenAiProviderRequestFinalization {
source_api_format: input.provider_api_format,
@@ -478,6 +497,8 @@ fn build_same_format_provider_request_body_inner(
upstream_is_stream: input.upstream_is_stream,
require_body_stream_field,
},
None,
reasoning_replay_policy,
)
.is_err()
{
@@ -2099,6 +2120,74 @@ mod tests {
}));
}
#[test]
fn same_format_responses_body_preserves_deepseek_opaque_reasoning_replay() {
let reasoning_items = (0..66)
.map(|index| {
json!({
"type": "reasoning",
"encrypted_content": format!("opaque-deepseek-state-{index}"),
"content": [{
"type": "reasoning_text",
"text": format!("provider thinking state {index}")
}]
})
})
.chain(std::iter::once(json!({
"type": "message",
"role": "user",
"content": "continue"
})))
.collect::<Vec<_>>();
let request_body = json!({
"model": "deepseek-v4-flash",
"input": reasoning_items
});
let input = SameFormatProviderRequestBodyInput {
body_json: &request_body,
mapped_model: "deepseek-v4-flash",
client_api_format: "openai:responses",
provider_api_format: "openai:responses",
source_model: Some("deepseek-v4-flash"),
family: SameFormatProviderFamily::Standard,
body_rules: None,
request_headers: None,
upstream_is_stream: false,
force_body_stream_field: false,
kiro_auth_config: None,
is_claude_code: false,
enable_model_directives: false,
};
let deepseek =
build_same_format_provider_request_body_with_compatibility_report_and_reasoning_replay_policy(
input,
aether_ai_formats::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque,
)
.expect("DeepSeek same-format Responses body should build");
let strict = build_same_format_provider_request_body_with_compatibility_report(input)
.expect("strict same-format Responses body should build");
assert_eq!(
deepseek.body["input"]
.as_array()
.expect("DeepSeek input array")
.iter()
.filter(|item| item["type"] == "reasoning")
.count(),
66
);
assert_eq!(
strict.body["input"]
.as_array()
.expect("strict input array")
.iter()
.filter(|item| item["type"] == "reasoning")
.count(),
0
);
}
#[test]
fn same_format_stream_policy_wins_after_body_rules() {
let body_rules = json!([