mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-12 20:19:51 +08:00
feat: audit strict format conversion contracts
This commit is contained in:
@@ -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() => {
|
||||
|
||||
Reference in New Issue
Block a user