feat: audit strict format conversion contracts

This commit is contained in:
elky
2026-06-03 20:27:15 +08:00
parent 5ac153a0bb
commit 7507af5829
28 changed files with 12206 additions and 203 deletions
@@ -1,4 +1,4 @@
use serde_json::{json, Value};
use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
@@ -103,7 +103,6 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
"top_p",
"top_k",
"stop",
"stream",
"tools",
"tool_choice",
"parallel_tool_calls",
@@ -206,12 +205,12 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value {
"openai",
&output,
));
output.extend(namespace_extension_object(
output.extend(chat_compatible_responses_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
&output,
));
output.extend(namespace_extension_object(
output.extend(chat_compatible_responses_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
&output,
@@ -219,6 +218,36 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value {
Value::Object(output)
}
fn chat_compatible_responses_extension_object(
extensions: &std::collections::BTreeMap<String, Value>,
namespace: &str,
existing: &Map<String, Value>,
) -> Map<String, Value> {
const CHAT_COMPATIBLE_RESPONSES_FIELDS: &[&str] = &[
"stream",
"stream_options",
"verbosity",
"store",
"service_tier",
"safety_identifier",
"prompt_cache_key",
];
extensions
.get(namespace)
.and_then(Value::as_object)
.map(|object| {
object
.iter()
.filter(|(key, _)| {
CHAT_COMPATIBLE_RESPONSES_FIELDS.contains(&key.as_str())
&& !existing.contains_key(*key)
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
})
.unwrap_or_default()
}
fn force_stream_options(body: &mut Value, upstream_is_stream: bool) {
if !upstream_is_stream {
return;
@@ -70,6 +70,13 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
}
let stop_reason =
openai_finish_reason_to_canonical(choice.get("finish_reason").and_then(Value::as_str));
let mut extensions = BTreeMap::new();
if let Some(raw_finish_reason) = choice.get("finish_reason").cloned() {
extensions.insert(
"openai".to_string(),
json!({ "raw_finish_reason": raw_finish_reason }),
);
}
outputs.push(CanonicalResponseOutput {
index: choice
.get("index")
@@ -79,7 +86,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
role: CanonicalRole::Assistant,
content,
stop_reason,
extensions: BTreeMap::new(),
extensions,
});
}
let first_output = outputs.first()?;
@@ -123,10 +130,21 @@ pub fn to_raw(canonical: &CanonicalResponse) -> Value {
.iter()
.enumerate()
.map(|(fallback_index, output)| {
let finish_reason = output
.extensions
.get("openai")
.and_then(Value::as_object)
.and_then(|openai| openai.get("raw_finish_reason"))
.cloned()
.unwrap_or_else(|| {
Value::String(
canonical_stop_reason_to_openai(output.stop_reason.as_ref()).to_string(),
)
});
json!({
"index": output.index,
"message": canonical_blocks_to_openai_chat_message(&output.content),
"finish_reason": canonical_stop_reason_to_openai(output.stop_reason.as_ref()),
"finish_reason": finish_reason,
})
.as_object()
.map(|choice| {
@@ -2217,7 +2217,12 @@ impl OpenAIResponsesClientEmitter {
Ok(out)
}
fn completed_response(&self, usage: CanonicalUsage) -> Value {
fn terminal_response(
&self,
usage: CanonicalUsage,
status: &str,
incomplete_reason: Option<&str>,
) -> Value {
let mut ordered_output = Vec::new();
let summary = if self.reasoning_summary_parts.is_empty() {
if self.reasoning.trim().is_empty() {
@@ -2336,17 +2341,29 @@ impl OpenAIResponsesClientEmitter {
}
ordered_output.sort_by_key(|(output_index, _)| *output_index);
json!({
let mut response = json!({
"id": self.response_id(),
"object": "response",
"status": "completed",
"status": status,
"model": self.model(),
"output": ordered_output
.into_iter()
.map(|(_, item)| item)
.collect::<Vec<_>>(),
"usage": openai_responses_usage_from_usage(&usage),
})
});
if let Some(reason) = incomplete_reason {
response["incomplete_details"] = json!({ "reason": reason });
}
response
}
fn completed_response(&self, usage: CanonicalUsage) -> Value {
self.terminal_response(usage, "completed", None)
}
fn incomplete_response(&self, usage: CanonicalUsage, reason: &str) -> Value {
self.terminal_response(usage, "incomplete", Some(reason))
}
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
@@ -2619,7 +2636,10 @@ impl OpenAIResponsesClientEmitter {
self.encode_response_event(event.as_str(), payload)
}
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
CanonicalStreamEvent::Finish { usage, .. } => {
CanonicalStreamEvent::Finish {
finish_reason,
usage,
} => {
if self.finished {
return Ok(Vec::new());
}
@@ -2629,11 +2649,22 @@ impl OpenAIResponsesClientEmitter {
out.extend(self.finish_tool_items()?);
out.extend(self.finish_tool_result_items()?);
let usage = usage.unwrap_or_default();
let (event_type, response) = match finish_reason.as_deref() {
Some("length") => (
"response.incomplete",
self.incomplete_response(usage, "max_output_tokens"),
),
Some("content_filter") => (
"response.incomplete",
self.incomplete_response(usage, "content_filter"),
),
_ => ("response.completed", self.completed_response(usage)),
};
out.extend(self.encode_response_event(
"response.completed",
event_type,
json!({
"type": "response.completed",
"response": self.completed_response(usage),
"type": event_type,
"response": response,
}),
)?);
self.finished = true;
@@ -4,7 +4,9 @@ use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort,
formats::openai::shared::{
map_thinking_budget_to_openai_reasoning_effort, OpenAiReasoningEffort,
},
protocol::canonical::{
canonical_response_format_to_openai, canonicalize_tool_arguments,
is_claude_messages_request, is_claude_system_instruction, is_claude_thinking_block,
@@ -182,6 +184,10 @@ pub fn to_raw(
output.insert("reasoning".to_string(), reasoning);
}
output.extend(chat_openai_extension_object_to_responses(
&canonical.extensions,
&output,
));
output.extend(namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
@@ -200,6 +206,33 @@ pub fn to_raw(
Some(Value::Object(output))
}
fn chat_openai_extension_object_to_responses(
extensions: &BTreeMap<String, Value>,
existing: &Map<String, Value>,
) -> Map<String, Value> {
const RESPONSES_COMPATIBLE_CHAT_FIELDS: &[&str] = &[
"stream",
"store",
"service_tier",
"safety_identifier",
"prompt_cache_key",
];
extensions
.get("openai")
.and_then(Value::as_object)
.map(|object| {
object
.iter()
.filter(|(key, _)| {
RESPONSES_COMPATIBLE_CHAT_FIELDS.contains(&key.as_str())
&& !existing.contains_key(*key)
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
})
.unwrap_or_default()
}
fn canonical_instructions_to_responses(canonical: &CanonicalRequest) -> Option<Value> {
let text = canonical
.instructions
@@ -582,7 +615,7 @@ fn canonical_reasoning_config_to_responses(canonical: &CanonicalRequest) -> Opti
.and_then(|value| value.get("output_config"))
.and_then(|value| value.get("effort"))
.and_then(Value::as_str)
.map(openai_responses_reasoning_effort)
.and_then(openai_responses_reasoning_effort)
.unwrap_or("medium");
object
.entry("effort".to_string())
@@ -608,10 +641,11 @@ fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<V
.get("openai")
.and_then(|value| value.get("reasoning_effort"))
.and_then(Value::as_str)
.map(|effort| {
json!({
"effort": openai_responses_reasoning_effort(effort),
})
.and_then(|effort| {
let effort = openai_responses_reasoning_effort(effort)?;
Some(json!({
"effort": effort,
}))
})
})
.or_else(|| {
@@ -623,13 +657,10 @@ fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<V
})
}
fn openai_responses_reasoning_effort(effort: &str) -> &str {
fn openai_responses_reasoning_effort(effort: &str) -> Option<&'static str> {
match effort.trim().to_ascii_lowercase().as_str() {
"xhigh" | "max" => "xhigh",
"low" => "low",
"medium" => "medium",
"high" => "high",
_ => effort,
"max" => Some("xhigh"),
value => OpenAiReasoningEffort::parse(value).map(OpenAiReasoningEffort::as_str),
}
}
@@ -694,6 +725,9 @@ fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
"parameters".to_string(),
responses_tool_parameters_schema(tool.parameters.as_ref()),
);
if let Some(strict) = tool.strict {
out.insert("strict".to_string(), Value::Bool(strict));
}
out.extend(namespace_extension_object(
&tool.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
@@ -5,7 +5,7 @@ use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
protocol::canonical::{
canonical_content_block_to_openai_responses_part,
canonical_content_block_to_openai_responses_part, canonical_extension_object_mut,
canonical_usage_to_openai_responses_usage, canonicalize_tool_arguments,
flush_openai_responses_message_item, namespace_extension_object,
openai_responses_extensions, openai_responses_output_to_canonical_blocks,
@@ -42,11 +42,19 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
Some(CanonicalStopReason::ToolUse)
} else {
match body.get("status").and_then(Value::as_str) {
Some("incomplete") => Some(CanonicalStopReason::MaxTokens),
Some("incomplete") => Some(openai_responses_incomplete_stop_reason(body)),
Some("failed") => Some(CanonicalStopReason::Unknown),
_ => Some(CanonicalStopReason::EndTurn),
}
};
let mut extensions = openai_responses_extensions(
body,
&["id", "object", "model", "output", "usage", "status"],
);
if let Some(raw_status) = body.get("status").cloned() {
canonical_extension_object_mut(&mut extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.insert("raw_status".to_string(), raw_status);
}
Some(CanonicalResponse {
id: body
.get("id")
@@ -68,13 +76,23 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
content,
stop_reason,
usage: openai_usage_to_canonical(body.get("usage")),
extensions: openai_responses_extensions(
body,
&["id", "object", "model", "output", "usage", "status"],
),
extensions,
})
}
fn openai_responses_incomplete_stop_reason(body: &Map<String, Value>) -> CanonicalStopReason {
match body
.get("incomplete_details")
.and_then(Value::as_object)
.and_then(|details| details.get("reason"))
.and_then(Value::as_str)
{
Some("content_filter") => CanonicalStopReason::ContentFiltered,
Some("tool_calls") | Some("function_call") => CanonicalStopReason::ToolUse,
_ => CanonicalStopReason::MaxTokens,
}
}
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: bool) -> Value {
let mut response = Map::new();
let response_id = canonical.id.replace("chatcmpl", "resp");
@@ -82,6 +100,20 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
response.insert("object".to_string(), Value::String("response".to_string()));
response.insert("status".to_string(), Value::String("completed".to_string()));
response.insert("model".to_string(), Value::String(canonical.model.clone()));
if let Some(raw_status) = canonical
.extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.or_else(|| {
canonical
.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.and_then(Value::as_object)
.and_then(|openai| openai.get("raw_status"))
.cloned()
{
response.insert("status".to_string(), raw_status);
}
let mut output = Vec::new();
let mut message_content = Vec::new();
@@ -272,16 +304,20 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
response.insert("service_tier".to_string(), service_tier);
}
}
response.extend(namespace_extension_object(
let mut extension_fields = namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
&response,
));
response.extend(namespace_extension_object(
);
extension_fields.remove("raw_status");
response.extend(extension_fields);
let mut legacy_extension_fields = namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
&response,
));
);
legacy_extension_fields.remove("raw_status");
response.extend(legacy_extension_fields);
Value::Object(response)
}
@@ -2,6 +2,41 @@ use serde_json::{Map, Value};
use crate::formats::shared::model_directives::ReasoningEffort;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenAiReasoningEffort {
None,
Minimal,
Low,
Medium,
High,
XHigh,
}
impl OpenAiReasoningEffort {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"none" => Some(Self::None),
"minimal" => Some(Self::Minimal),
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"xhigh" => Some(Self::XHigh),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
}
}
}
pub fn parse_openai_stop_sequences(stop: Option<&Value>) -> Option<Vec<Value>> {
match stop {
Some(Value::String(value)) if !value.trim().is_empty() => {