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
+9 -6
View File
@@ -251,16 +251,19 @@ pub use aether_ai_formats::{
canonical_to_openai_chat_response, canonical_to_openai_responses_compact_request,
canonical_to_openai_responses_compact_response, canonical_to_openai_responses_request,
canonical_to_openai_responses_response, canonical_unknown_block_count, convert_request,
convert_response, from_claude_to_canonical_request, from_claude_to_canonical_response,
from_gemini_to_canonical_request, from_gemini_to_canonical_response,
from_openai_chat_to_canonical_request, from_openai_chat_to_canonical_response,
from_openai_responses_to_canonical_request, from_openai_responses_to_canonical_response,
convert_request_pure, convert_request_pure_with_context, convert_response,
convert_response_pure, emit_request_pure, emit_response_pure, from_claude_to_canonical_request,
from_claude_to_canonical_response, from_gemini_to_canonical_request,
from_gemini_to_canonical_response, from_openai_chat_to_canonical_request,
from_openai_chat_to_canonical_response, from_openai_responses_to_canonical_request,
from_openai_responses_to_canonical_response, parse_request_pure, parse_response_pure,
request_candidate_api_format_preference, request_candidate_api_formats,
request_conversion_kind, request_conversion_requires_enable_flag,
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, CanonicalContentBlock,
CanonicalGenerationConfig, CanonicalInstruction, CanonicalMessage, CanonicalRequest,
CanonicalResponse, CanonicalResponseFormat, CanonicalResponseOutput, CanonicalRole,
CanonicalStopReason, CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition,
CanonicalUsage, FormatContext, FormatError, FormatFamily, FormatId, FormatProfile,
RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
CanonicalUsage, ConversionFieldRecord, ConversionFieldStatus, ConversionReport, Converted,
FormatContext, FormatError, FormatFamily, FormatId, FormatProfile, RequestConversionKind,
SyncChatResponseConversionKind, SyncCliResponseConversionKind,
};
@@ -1,11 +1,58 @@
use serde_json::{Map, Value};
use crate::formats::context::FormatContext;
use crate::formats::openai::embedding::request::mapped_embedding_model;
use crate::formats::openai::embedding::request::{mapped_embedding_model, namespace_extensions};
use crate::protocol::canonical::{
CanonicalEmbeddingContent, CanonicalEmbeddingInput, CanonicalRequest,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
from_raw(body)
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
let request = body_json.as_object()?;
let model = request
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let contents = request
.get("input")
.and_then(Value::as_object)
.and_then(|input| input.get("contents"))
.and_then(Value::as_array)?;
let input = contents_to_embedding_input(contents)?;
let mut parameters = request
.get("parameters")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let dimensions = parameters
.remove("dimension")
.or_else(|| request.get("dimensions").cloned())
.and_then(|value| value.as_u64());
let parameters = (!parameters.is_empty()).then_some(parameters);
Some(CanonicalRequest {
model,
embedding: Some(crate::protocol::canonical::CanonicalEmbeddingRequest {
input,
encoding_format: None,
dimensions,
task: None,
user: None,
parameters,
extensions: namespace_extensions(
"aliyun",
request,
&["model", "input", "parameters", "dimensions"],
),
}),
..CanonicalRequest::default()
})
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
let embedding = request.embedding.as_ref()?;
let contents = embedding_input_to_contents(&embedding.input)?;
@@ -42,6 +89,67 @@ pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
Some(Value::Object(output))
}
fn contents_to_embedding_input(contents: &[Value]) -> Option<CanonicalEmbeddingInput> {
if contents.is_empty() {
return None;
}
let parsed = contents
.iter()
.map(embedding_content_from_value)
.collect::<Option<Vec<_>>>()?;
if parsed.iter().all(|content| {
content.image.is_none() && content.video.is_none() && content.multi_images.is_none()
}) {
return Some(CanonicalEmbeddingInput::StringArray(
parsed
.into_iter()
.map(|content| content.text)
.collect::<Option<Vec<_>>>()?,
));
}
Some(CanonicalEmbeddingInput::Multimodal(parsed))
}
fn embedding_content_from_value(value: &Value) -> Option<CanonicalEmbeddingContent> {
let object = value.as_object()?;
let content = CanonicalEmbeddingContent {
text: object
.get("text")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
image: object
.get("image")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
video: object
.get("video")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
multi_images: match object.get("multi_images").and_then(Value::as_array) {
Some(values) => Some(
values
.iter()
.map(|value| {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.collect::<Option<Vec<_>>>()?,
),
None => None,
},
};
(!content.is_empty()).then_some(content)
}
fn embedding_input_to_contents(input: &CanonicalEmbeddingInput) -> Option<Vec<Value>> {
match input {
CanonicalEmbeddingInput::String(text) => {
@@ -5,7 +5,8 @@ use serde_json::{json, Value};
use crate::{
formats::context::FormatContext,
protocol::canonical::{
canonical_blocks_to_claude, canonical_stop_reason_to_claude, canonical_usage_to_claude,
canonical_blocks_to_claude, canonical_extension_object_mut,
canonical_stop_reason_to_claude, canonical_usage_to_claude,
claude_content_to_canonical_blocks, claude_extensions, claude_stop_reason_to_canonical,
claude_usage_to_canonical, namespace_extension_object, CanonicalResponse,
CanonicalResponseOutput, CanonicalRole,
@@ -28,6 +29,27 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
let content = claude_content_to_canonical_blocks(body.get("content"))?;
let stop_reason =
claude_stop_reason_to_canonical(body.get("stop_reason").and_then(Value::as_str));
let mut extensions = claude_extensions(
body,
&[
"id",
"type",
"role",
"model",
"content",
"stop_reason",
"stop_sequence",
"usage",
],
);
if let Some(raw_stop_reason) = body.get("stop_reason").cloned() {
canonical_extension_object_mut(&mut extensions, "claude")
.insert("raw_stop_reason".to_string(), raw_stop_reason);
}
if let Some(raw_stop_sequence) = body.get("stop_sequence").cloned() {
canonical_extension_object_mut(&mut extensions, "claude")
.insert("raw_stop_sequence".to_string(), raw_stop_sequence);
}
Some(CanonicalResponse {
id: body
.get("id")
@@ -49,19 +71,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
content,
stop_reason,
usage: claude_usage_to_canonical(body.get("usage")),
extensions: claude_extensions(
body,
&[
"id",
"type",
"role",
"model",
"content",
"stop_reason",
"stop_sequence",
"usage",
],
),
extensions,
})
}
@@ -86,12 +96,23 @@ pub fn to_raw(canonical: &CanonicalResponse) -> Value {
"output_tokens": 0,
})),
});
if let Some(claude) = canonical
.extensions
.get("claude")
.and_then(Value::as_object)
{
if let Some(raw_stop_reason) = claude.get("raw_stop_reason").cloned() {
response["stop_reason"] = raw_stop_reason;
}
if let Some(raw_stop_sequence) = claude.get("raw_stop_sequence").cloned() {
response["stop_sequence"] = raw_stop_sequence;
}
}
if let Some(object) = response.as_object_mut() {
object.extend(namespace_extension_object(
&canonical.extensions,
"claude",
object,
));
let mut extra = namespace_extension_object(&canonical.extensions, "claude", object);
extra.remove("raw_stop_reason");
extra.remove("raw_stop_sequence");
object.extend(extra);
}
response
}
@@ -322,8 +322,7 @@ impl ClaudeProviderState {
let finish_reason = map_claude_stop_reason(
delta.get("stop_reason").and_then(Value::as_str),
delta.get("stop_reason").and_then(Value::as_str) == Some("tool_use"),
)
.map(ToOwned::to_owned);
);
let (id, model) = self.identity(report_context);
out.push(CanonicalStreamFrame {
id,
+142 -4
View File
@@ -1,5 +1,6 @@
use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, Clone, Default)]
@@ -31,6 +32,15 @@ impl FormatContext {
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(),
}
}
pub(crate) fn mapped_model_or<'a>(&'a self, fallback: &'a str) -> &'a str {
self.mapped_model
.as_deref()
@@ -47,13 +57,109 @@ impl FormatContext {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ConversionFieldStatus {
Native,
Mapped,
ExtensionPreserved,
Unsupported,
InvalidEnum,
LossyBlocked,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConversionFieldRecord {
pub field: String,
pub status: ConversionFieldStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
impl ConversionFieldRecord {
pub fn new(
field: impl Into<String>,
status: ConversionFieldStatus,
detail: Option<String>,
) -> Self {
Self {
field: field.into(),
status,
detail,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConversionReport {
pub source_format: String,
pub target_format: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<ConversionFieldRecord>,
}
impl ConversionReport {
pub fn new(source_format: impl Into<String>, target_format: impl Into<String>) -> Self {
Self {
source_format: source_format.into(),
target_format: target_format.into(),
fields: Vec::new(),
}
}
pub fn record(
&mut self,
field: impl Into<String>,
status: ConversionFieldStatus,
detail: Option<String>,
) {
self.fields
.push(ConversionFieldRecord::new(field, status, detail));
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Converted<T> {
pub value: T,
pub report: ConversionReport,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormatError {
UnsupportedFormat(String),
RequestParseFailed { format: String },
RequestEmitFailed { format: String },
ResponseParseFailed { format: String },
ResponseEmitFailed { format: String },
RequestParseFailed {
format: String,
},
RequestEmitFailed {
format: String,
},
ResponseParseFailed {
format: String,
},
ResponseEmitFailed {
format: String,
},
UnsupportedField {
format: String,
field: String,
reason: String,
},
InvalidEnumValue {
format: String,
field: String,
value: String,
},
LossyConversionBlocked {
source_format: String,
target_format: String,
field: String,
reason: String,
},
InvalidTargetField {
format: String,
field: String,
reason: String,
},
}
impl fmt::Display for FormatError {
@@ -70,6 +176,38 @@ impl fmt::Display for FormatError {
Self::ResponseEmitFailed { format } => {
write!(f, "failed to emit {format} response")
}
Self::UnsupportedField {
format,
field,
reason,
} => {
write!(f, "unsupported field {field} in {format}: {reason}")
}
Self::InvalidEnumValue {
format,
field,
value,
} => {
write!(f, "invalid enum value {value:?} for {format}.{field}")
}
Self::LossyConversionBlocked {
source_format,
target_format,
field,
reason,
} => {
write!(
f,
"lossy conversion blocked from {source_format} to {target_format} at {field}: {reason}"
)
}
Self::InvalidTargetField {
format,
field,
reason,
} => {
write!(f, "invalid target field {field} for {format}: {reason}")
}
}
}
}
@@ -5,6 +5,10 @@ use crate::formats::context::FormatContext;
use crate::formats::openai::embedding::request::mapped_embedding_model;
use crate::protocol::canonical::{namespace_extension_object, CanonicalRequest};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
crate::formats::openai::embedding::request::from_namespace(body, "doubao")
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
let embedding = request.embedding.as_ref()?;
let items = embedding.input.as_string_items()?;
@@ -1,8 +1,137 @@
use serde_json::{json, Map, Value};
use crate::formats::context::FormatContext;
use crate::formats::openai::embedding::request::mapped_embedding_model;
use crate::protocol::canonical::{CanonicalEmbeddingRequest, CanonicalRequest};
use crate::formats::openai::embedding::request::{mapped_embedding_model, namespace_extensions};
use crate::protocol::canonical::{
CanonicalEmbeddingInput, CanonicalEmbeddingRequest, CanonicalRequest,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
from_raw(body)
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
let request = body_json.as_object()?;
if let Some(requests) = request.get("requests").and_then(Value::as_array) {
return from_batch_requests(request, requests);
}
let item = parse_gemini_embedding_request_object(request)?;
Some(CanonicalRequest {
model: item.model,
embedding: Some(CanonicalEmbeddingRequest {
input: CanonicalEmbeddingInput::String(item.text),
encoding_format: None,
dimensions: item.dimensions,
task: item.task,
user: None,
parameters: None,
extensions: namespace_extensions(
"gemini",
request,
&[
"model",
"content",
"outputDimensionality",
"output_dimensionality",
"taskType",
"task_type",
],
),
}),
..CanonicalRequest::default()
})
}
fn from_batch_requests(
request: &Map<String, Value>,
requests: &[Value],
) -> Option<CanonicalRequest> {
if requests.is_empty() {
return None;
}
let items = requests
.iter()
.map(|request| parse_gemini_embedding_request_object(request.as_object()?))
.collect::<Option<Vec<_>>>()?;
let first = items.first()?;
if items.iter().any(|item| {
item.model != first.model || item.dimensions != first.dimensions || item.task != first.task
}) {
return None;
}
let model = first.model.clone();
let dimensions = first.dimensions;
let task = first.task.clone();
Some(CanonicalRequest {
model,
embedding: Some(CanonicalEmbeddingRequest {
input: CanonicalEmbeddingInput::StringArray(
items.into_iter().map(|item| item.text).collect(),
),
encoding_format: None,
dimensions,
task,
user: None,
parameters: None,
extensions: namespace_extensions("gemini", request, &["requests"]),
}),
..CanonicalRequest::default()
})
}
struct ParsedGeminiEmbeddingRequest {
model: String,
text: String,
dimensions: Option<u64>,
task: Option<String>,
}
fn parse_gemini_embedding_request_object(
request: &Map<String, Value>,
) -> Option<ParsedGeminiEmbeddingRequest> {
let model = request
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let parts = request
.get("content")
.and_then(Value::as_object)
.and_then(|content| content.get("parts"))
.and_then(Value::as_array)?;
let text = parts
.iter()
.map(|part| {
part.as_object()?
.get("text")?
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.collect::<Option<Vec<_>>>()?
.join("\n");
if text.trim().is_empty() {
return None;
}
Some(ParsedGeminiEmbeddingRequest {
model,
text,
dimensions: request
.get("outputDimensionality")
.or_else(|| request.get("output_dimensionality"))
.and_then(Value::as_u64),
task: request
.get("taskType")
.or_else(|| request.get("task_type"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
})
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
let embedding = request.embedding.as_ref()?;
@@ -352,6 +352,7 @@ fn canonical_block_to_gemini_part(
..
} => Some(Some(json!({
"functionResponse": {
"id": tool_use_id,
"name": name.clone()
.or_else(|| tool_name_by_id.get(tool_use_id).cloned())
.unwrap_or_else(|| tool_use_id.clone()),
@@ -676,12 +677,21 @@ fn canonical_tool_to_gemini_declaration(tool: &CanonicalToolDefinition) -> Value
Value::String(description.clone()),
);
}
let raw_parameters = tool
.extensions
.get("gemini")
.and_then(Value::as_object)
.and_then(|value| value.get("raw_parameters"))
.cloned();
declaration.insert(
"parameters".to_string(),
tool.parameters
raw_parameters
.clone()
.or_else(|| tool.parameters.clone())
.map(|mut schema| {
clean_gemini_schema(&mut schema);
if raw_parameters.is_none() {
clean_gemini_schema(&mut schema);
}
schema
})
.unwrap_or_else(|| json!({})),
@@ -808,7 +818,7 @@ mod tests {
use crate::CanonicalContentBlock;
#[test]
fn canonical_tool_result_to_gemini_request_omits_function_response_id() {
fn canonical_tool_result_to_gemini_request_preserves_function_response_id() {
let mut tool_name_by_id = BTreeMap::new();
tool_name_by_id.insert("call_1".to_string(), "lookup".to_string());
@@ -831,7 +841,7 @@ mod tests {
.and_then(Value::as_object)
.expect("functionResponse should exist");
assert!(!function_response.contains_key("id"));
assert_eq!(function_response["id"], "call_1");
assert_eq!(function_response["name"], "lookup");
assert_eq!(
function_response["response"],
@@ -55,6 +55,18 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
{
stop_reason = Some(CanonicalStopReason::ToolUse);
}
let mut extensions = gemini_extensions(
candidate_object,
&["index", "content", "finishReason", "finish_reason"],
);
if let Some(raw_finish_reason) = candidate_object
.get("finishReason")
.or_else(|| candidate_object.get("finish_reason"))
.cloned()
{
canonical_extension_object_mut(&mut extensions, "gemini")
.insert("raw_finish_reason".to_string(), raw_finish_reason);
}
outputs.push(CanonicalResponseOutput {
index: candidate_object
.get("index")
@@ -64,10 +76,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
role: CanonicalRole::Assistant,
content,
stop_reason,
extensions: gemini_extensions(
candidate_object,
&["index", "content", "finishReason", "finish_reason"],
),
extensions,
});
}
outputs.retain(gemini_response_output_has_visible_content);
@@ -177,7 +186,13 @@ fn canonical_to_gemini_response(
});
if let Some(candidate_object) = candidate.as_object_mut() {
if let Some(gemini) = output.extensions.get("gemini").and_then(Value::as_object) {
if let Some(raw_finish_reason) = gemini.get("raw_finish_reason").cloned() {
candidate_object.insert("finishReason".to_string(), raw_finish_reason);
}
for (key, value) in gemini {
if key == "raw_finish_reason" {
continue;
}
candidate_object.entry(key.clone()).or_insert(value.clone());
}
}
@@ -298,13 +298,8 @@ impl GeminiProviderState {
candidate_object.get("finishReason").and_then(Value::as_str)
{
let has_tool_calls = !self.tool_calls.is_empty();
let mut finish_reason = normalize_openai_finish_reason(match finish_reason {
"STOP" => Some("stop"),
"MAX_TOKENS" => Some("length"),
"SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII"
| "OTHER" => Some("content_filter"),
other => Some(other),
});
let mut finish_reason =
normalize_openai_finish_reason(map_gemini_stream_finish_reason(finish_reason));
if has_tool_calls && finish_reason.as_deref().is_none_or(|value| value == "stop") {
finish_reason = Some("tool_calls".to_string());
}
@@ -343,6 +338,23 @@ impl GeminiProviderState {
}
}
fn map_gemini_stream_finish_reason(value: &str) -> Option<&str> {
match value {
"STOP" => Some("stop"),
"MAX_TOKENS" => Some("length"),
"SAFETY"
| "RECITATION"
| "LANGUAGE"
| "BLOCKLIST"
| "PROHIBITED_CONTENT"
| "SPII"
| "IMAGE_SAFETY"
| "IMAGE_PROHIBITED_CONTENT"
| "IMAGE_RECITATION" => Some("content_filter"),
other => Some(other),
}
}
#[derive(Default)]
struct GeminiClientToolState {
call_id: String,
@@ -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() => {
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,8 @@ pub enum ModelOverride {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReasoningEffort {
None,
Minimal,
Low,
Medium,
High,
@@ -24,6 +26,8 @@ pub enum ReasoningEffort {
impl ReasoningEffort {
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),
@@ -35,6 +39,8 @@ impl ReasoningEffort {
pub fn as_openai_chat_value(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
@@ -45,6 +51,8 @@ impl ReasoningEffort {
pub fn as_openai_responses_value(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
@@ -54,6 +62,7 @@ impl ReasoningEffort {
pub fn as_claude_output_value(self) -> &'static str {
match self {
Self::None | Self::Minimal => "low",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
@@ -64,7 +73,7 @@ impl ReasoningEffort {
pub fn as_gemini_level_value(self) -> &'static str {
match self {
Self::Low => "low",
Self::None | Self::Minimal | Self::Low => "low",
Self::Medium => "medium",
Self::High | Self::XHigh | Self::Max => "high",
}
@@ -72,6 +81,8 @@ impl ReasoningEffort {
pub fn thinking_budget_tokens(self) -> u64 {
match self {
Self::None => 0,
Self::Minimal => 512,
Self::Low => 1280,
Self::Medium => 2048,
Self::High => 4096,
@@ -2,19 +2,17 @@ use serde_json::Value;
use crate::formats::shared::AiSurfaceFinalizeError;
pub fn map_claude_stop_reason(
stop_reason: Option<&str>,
has_tool_calls: bool,
) -> Option<&'static str> {
pub fn map_claude_stop_reason(stop_reason: Option<&str>, has_tool_calls: bool) -> Option<String> {
let mapped = match stop_reason {
Some("end_turn") | Some("stop_sequence") => Some("stop"),
Some("max_tokens") => Some("length"),
Some("tool_use") => Some("tool_calls"),
Some("pause_turn") => Some("stop"),
Some("end_turn") | Some("stop_sequence") => Some("stop".to_string()),
Some("max_tokens") => Some("length".to_string()),
Some("tool_use") => Some("tool_calls".to_string()),
Some("pause_turn") => Some("stop".to_string()),
Some(other) if !other.trim().is_empty() => Some(other.to_string()),
_ => None,
};
if has_tool_calls && mapped.is_none_or(|value| value == "stop") {
Some("tool_calls")
if has_tool_calls && mapped.as_deref().is_none_or(|value| value == "stop") {
Some("tool_calls".to_string())
} else {
mapped
}
@@ -84,6 +84,22 @@ impl StreamingStandardFormatMatrix {
};
let mut out = Vec::new();
for frame in frames {
if let CanonicalStreamEvent::Finish {
finish_reason: Some(ref finish_reason),
..
} = frame.event
{
if !canonical_stream_finish_reason_is_supported(finish_reason) {
self.terminated = true;
out.extend(client.emit_unsupported_finish_reason(finish_reason)?);
break;
}
}
if matches!(&frame.event, CanonicalStreamEvent::UnknownEvent(_)) {
self.terminated = true;
out.extend(client.emit_unknown_event()?);
break;
}
out.extend(client.emit(frame)?);
}
Ok(out)
@@ -386,6 +402,51 @@ impl ClientStreamEmitter {
}
}
}
fn emit_unknown_event(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let Some(error_body) = build_core_error_body_for_client_format(
self.api_format(),
"Unsupported provider stream event cannot be converted losslessly",
Some("unsupported_stream_event"),
LocalCoreSyncErrorKind::ServerError,
) else {
return Ok(Vec::new());
};
self.emit_error(error_body)
}
fn emit_unsupported_finish_reason(
&mut self,
finish_reason: &str,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let Some(error_body) = build_core_error_body_for_client_format(
self.api_format(),
&format!(
"Unsupported provider stream finish reason {finish_reason:?} cannot be converted losslessly"
),
Some("unsupported_finish_reason"),
LocalCoreSyncErrorKind::ServerError,
) else {
return Ok(Vec::new());
};
self.emit_error(error_body)
}
fn api_format(&self) -> &'static str {
match self {
ClientStreamEmitter::OpenAIChat(_) => "openai:chat",
ClientStreamEmitter::OpenAIResponses(_) => "openai:responses",
ClientStreamEmitter::Claude(_) => "claude:messages",
ClientStreamEmitter::Gemini(_) => "gemini:generate_content",
}
}
}
fn canonical_stream_finish_reason_is_supported(finish_reason: &str) -> bool {
matches!(
finish_reason.trim(),
"stop" | "length" | "tool_calls" | "function_call" | "content_filter"
)
}
fn build_client_error_body_for_line(report_context: &Value, line: &[u8]) -> Option<Value> {
@@ -850,6 +911,299 @@ mod tests {
}
}
#[test]
fn transforms_unknown_provider_stream_events_to_visible_client_errors() {
let cases = [
(
"openai:chat",
"data: {\"error\":",
"\"code\":\"unsupported_stream_event\"",
),
(
"openai:responses",
"event: response.failed\n",
"\"code\":\"unsupported_stream_event\"",
),
(
"claude:messages",
"event: error\n",
"\"code\":\"unsupported_stream_event\"",
),
(
"gemini:generate_content",
"data: {\"error\":",
"\"status\":\"INTERNAL\"",
),
];
for (client_api_format, prefix, marker) in cases {
let mut report_context = report_context("openai:responses", client_api_format);
report_context["provider_stream_event_api_format"] = json!("openai:responses");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(
&report_context,
data_line(json!({
"type": "response.future.delta",
"response": {
"id": "resp_unknown_123",
"model": "gpt-5.4",
},
"payload": {
"kept": true,
},
})),
)
.expect("unknown provider event should fail closed visibly");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.contains(prefix), "{client_api_format}: {sse}");
assert!(
sse.contains("Unsupported provider stream event cannot be converted losslessly"),
"{client_api_format}: {sse}"
);
assert!(sse.contains(marker), "{client_api_format}: {sse}");
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
assert!(matrix
.transform_line(
&report_context,
data_line(json!({
"type": "response.output_text.delta",
"response_id": "resp_unknown_123",
"output_index": 0,
"content_index": 0,
"delta": "after",
})),
)
.expect("terminated matrix should ignore later lines")
.is_empty());
}
}
#[test]
fn transforms_unknown_stream_finish_reasons_to_visible_client_errors() {
let cases = [
(
"openai:chat",
"data: {\"error\":",
"\"code\":\"unsupported_finish_reason\"",
),
(
"openai:responses",
"event: response.failed\n",
"\"code\":\"unsupported_finish_reason\"",
),
(
"claude:messages",
"event: error\n",
"\"code\":\"unsupported_finish_reason\"",
),
(
"gemini:generate_content",
"data: {\"error\":",
"\"status\":\"INTERNAL\"",
),
];
for (client_api_format, prefix, marker) in cases {
let report_context = report_context("openai:chat", client_api_format);
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(
&report_context,
data_line(json!({
"id": "chatcmpl_unknown_finish",
"object": "chat.completion.chunk",
"model": "gpt-5.4",
"choices": [{
"index": 0,
"delta": {},
"finish_reason": "future_reason"
}],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 2,
"total_tokens": 3
}
})),
)
.expect("unknown finish reason should fail closed visibly");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.contains(prefix), "{client_api_format}: {sse}");
assert!(
sse.contains("Unsupported provider stream finish reason"),
"{client_api_format}: {sse}"
);
assert!(sse.contains("future_reason"), "{client_api_format}: {sse}");
assert!(sse.contains(marker), "{client_api_format}: {sse}");
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
}
#[test]
fn transforms_unmappable_gemini_stream_finish_reasons_to_visible_client_errors() {
let cases = [
(
"openai:chat",
"data: {\"error\":",
"\"code\":\"unsupported_finish_reason\"",
),
(
"openai:responses",
"event: response.failed\n",
"\"code\":\"unsupported_finish_reason\"",
),
(
"claude:messages",
"event: error\n",
"\"code\":\"unsupported_finish_reason\"",
),
(
"gemini:generate_content",
"data: {\"error\":",
"\"status\":\"INTERNAL\"",
),
];
for (client_api_format, prefix, marker) in cases {
let report_context = report_context("gemini:generate_content", client_api_format);
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(
&report_context,
data_line(json!({
"responseId": "gemini_unmappable_finish",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"index": 0,
"content": {
"role": "model",
"parts": [{"text": "partial"}]
},
"finishReason": "OTHER"
}],
"usageMetadata": {
"promptTokenCount": 1,
"candidatesTokenCount": 2,
"totalTokenCount": 3
}
})),
)
.expect("unmappable Gemini finish reason should fail closed visibly");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.contains(prefix), "{client_api_format}: {sse}");
assert!(
sse.contains("Unsupported provider stream finish reason"),
"{client_api_format}: {sse}"
);
assert!(sse.contains("OTHER"), "{client_api_format}: {sse}");
assert!(sse.contains(marker), "{client_api_format}: {sse}");
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
}
#[test]
fn transforms_unknown_claude_stream_stop_reasons_to_visible_client_errors() {
let cases = [
(
"openai:chat",
"data: {\"error\":",
"\"code\":\"unsupported_finish_reason\"",
),
(
"openai:responses",
"event: response.failed\n",
"\"code\":\"unsupported_finish_reason\"",
),
(
"claude:messages",
"event: error\n",
"\"code\":\"unsupported_finish_reason\"",
),
(
"gemini:generate_content",
"data: {\"error\":",
"\"status\":\"INTERNAL\"",
),
];
for (client_api_format, prefix, marker) in cases {
let report_context = report_context("claude:messages", client_api_format);
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(
&report_context,
data_line(json!({
"type": "message_delta",
"delta": {
"stop_reason": "future_reason"
},
"usage": {
"input_tokens": 1,
"output_tokens": 2
}
})),
)
.expect("unknown Claude stop reason should fail closed visibly");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.contains(prefix), "{client_api_format}: {sse}");
assert!(
sse.contains("Unsupported provider stream finish reason"),
"{client_api_format}: {sse}"
);
assert!(sse.contains("future_reason"), "{client_api_format}: {sse}");
assert!(sse.contains(marker), "{client_api_format}: {sse}");
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
}
#[test]
fn openai_responses_client_emits_incomplete_for_length_finish_reason() {
let report_context = report_context("openai:chat", "openai:responses");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(
&report_context,
data_line(json!({
"id": "chatcmpl_length_finish",
"object": "chat.completion.chunk",
"model": "gpt-5.4",
"choices": [{
"index": 0,
"delta": {},
"finish_reason": "length"
}],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 2,
"total_tokens": 3
}
})),
)
.expect("length finish reason should map to response.incomplete");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.contains("event: response.incomplete\n"));
assert!(sse.contains("\"status\":\"incomplete\""));
assert!(sse.contains("\"incomplete_details\":{\"reason\":\"max_output_tokens\"}"));
assert!(!sse.contains("event: response.completed\n"));
}
#[test]
fn rewrites_gemini_inline_image_streams_to_claude_image_blocks() {
let report_context = report_context("gemini:generate_content", "claude:messages");
@@ -7,7 +7,7 @@ use aether_ai_formats::formats::conversion::response::{
convert_openai_chat_response_to_openai_responses,
convert_openai_responses_response_to_openai_chat,
};
use aether_ai_formats::formats::registry::{convert_response, FormatContext};
use aether_ai_formats::formats::registry::{convert_response, FormatContext, FormatError};
use aether_ai_formats::{
canonical_to_claude_response, canonical_to_embedding_response, canonical_to_gemini_response,
canonical_to_openai_chat_response, canonical_to_openai_responses_compact_response,
@@ -19,7 +19,9 @@ use aether_ai_formats::{
use serde_json::{json, Map, Value};
use super::AiSurfaceFinalizeError;
use crate::formats::claude::messages::stream::ClaudeProviderState;
use crate::formats::gemini::generate_content::stream::GeminiProviderState;
use crate::formats::openai::chat::stream::{OpenAIChatProviderState, OpenAIResponsesProviderState};
use crate::formats::shared::model_directives::model_directive_display_model_from_report_context;
use crate::formats::shared::response::{
remove_empty_pages_from_tool_arguments, remove_empty_pages_from_tool_input_value,
@@ -28,7 +30,7 @@ use crate::formats::shared::response::{
use crate::formats::shared::stream_core::common::{
content_part_from_openai_image_generation_item, gemini_usage_metadata_from_usage,
map_openai_finish_reason_to_gemini, parse_json_arguments_value, CanonicalContentPart,
CanonicalStreamEvent, CanonicalUsage,
CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
};
#[derive(Clone, Debug, PartialEq)]
@@ -70,9 +72,9 @@ pub fn maybe_build_standard_cross_format_sync_product_from_normalized_payload(
Some(body_base64) => {
let body_bytes = base64::engine::general_purpose::STANDARD.decode(body_base64)?;
if is_standard_chat_finalize_kind(report_kind) {
aggregate_standard_chat_stream_sync_response(&body_bytes, provider_api_format)
try_aggregate_standard_chat_stream_sync_response(&body_bytes, provider_api_format)?
} else if is_standard_cli_finalize_kind(report_kind) {
aggregate_standard_cli_stream_sync_response(&body_bytes, provider_api_format)
try_aggregate_standard_cli_stream_sync_response(&body_bytes, provider_api_format)?
} else {
return Ok(None);
}
@@ -544,9 +546,9 @@ fn maybe_build_standard_same_format_stream_sync_body(
};
let body_bytes = base64::engine::general_purpose::STANDARD.decode(body_base64)?;
Ok(
aggregate_same_format_stream_sync_response(expected_api_format, &body_bytes).map(|body| {
client_body_with_report_context_model(body, report_context, &client_api_format)
}),
try_aggregate_same_format_stream_sync_response(expected_api_format, &body_bytes)?.map(
|body| client_body_with_report_context_model(body, report_context, &client_api_format),
),
)
}
@@ -646,7 +648,7 @@ fn maybe_build_openai_responses_same_family_stream_sync_body(
};
let body_bytes = base64::engine::general_purpose::STANDARD.decode(body_base64)?;
Ok(
aggregate_openai_responses_stream_sync_response(&body_bytes).map(|body| {
try_aggregate_openai_responses_stream_sync_response(&body_bytes)?.map(|body| {
client_body_with_report_context_model(body, report_context, &client_api_format)
}),
)
@@ -663,10 +665,12 @@ fn maybe_build_openai_cross_format_provider_body_from_normalized_payload(
let normalized_provider_api_format =
normalize_openai_responses_family_api_format(provider_api_format);
match normalized_provider_api_format.as_str() {
"claude:messages" => aggregate_claude_stream_sync_response(&body_bytes),
"gemini:generate_content" => aggregate_gemini_stream_sync_response(&body_bytes),
"claude:messages" => try_aggregate_claude_stream_sync_response(&body_bytes)?,
"gemini:generate_content" => {
try_aggregate_gemini_stream_sync_response(&body_bytes)?
}
"openai:responses" | "openai:responses:compact" => {
aggregate_openai_responses_stream_sync_response(&body_bytes)
try_aggregate_openai_responses_stream_sync_response(&body_bytes)?
}
_ => None,
}
@@ -759,14 +763,23 @@ pub fn aggregate_standard_chat_stream_sync_response(
body: &[u8],
provider_api_format: &str,
) -> Option<Value> {
try_aggregate_standard_chat_stream_sync_response(body, provider_api_format)
.ok()
.flatten()
}
fn try_aggregate_standard_chat_stream_sync_response(
body: &[u8],
provider_api_format: &str,
) -> Result<Option<Value>, AiSurfaceFinalizeError> {
match aether_ai_formats::normalize_api_format_alias(provider_api_format).as_str() {
"openai:chat" => aggregate_openai_chat_stream_sync_response(body),
"openai:chat" => try_aggregate_openai_chat_stream_sync_response(body),
"openai:responses" | "openai:responses:compact" => {
aggregate_openai_responses_stream_sync_response(body)
try_aggregate_openai_responses_stream_sync_response(body)
}
"claude:messages" => aggregate_claude_stream_sync_response(body),
"gemini:generate_content" => aggregate_gemini_stream_sync_response(body),
_ => None,
"claude:messages" => try_aggregate_claude_stream_sync_response(body),
"gemini:generate_content" => try_aggregate_gemini_stream_sync_response(body),
_ => Ok(None),
}
}
@@ -776,13 +789,15 @@ pub fn convert_standard_chat_response(
client_api_format: &str,
report_context: &Value,
) -> Option<Value> {
if let Ok(converted) = convert_response(
match convert_response(
provider_api_format,
client_api_format,
body_json,
&format_context_from_report_context(report_context),
) {
return Some(converted);
Ok(converted) => return Some(converted),
Err(error) if response_conversion_error_requires_fail_closed(&error) => return None,
Err(_) => {}
}
if matches!(
@@ -835,19 +850,28 @@ pub fn aggregate_standard_cli_stream_sync_response(
aggregate_standard_chat_stream_sync_response(body, provider_api_format)
}
fn try_aggregate_standard_cli_stream_sync_response(
body: &[u8],
provider_api_format: &str,
) -> Result<Option<Value>, AiSurfaceFinalizeError> {
try_aggregate_standard_chat_stream_sync_response(body, provider_api_format)
}
pub fn convert_standard_cli_response(
body_json: &Value,
provider_api_format: &str,
client_api_format: &str,
report_context: &Value,
) -> Option<Value> {
if let Ok(converted) = convert_response(
match convert_response(
provider_api_format,
client_api_format,
body_json,
&format_context_from_report_context(report_context),
) {
return Some(converted);
Ok(converted) => return Some(converted),
Err(error) if response_conversion_error_requires_fail_closed(&error) => return None,
Err(_) => {}
}
if matches!(
@@ -907,6 +931,16 @@ pub fn convert_standard_cli_response(
}
}
fn response_conversion_error_requires_fail_closed(error: &FormatError) -> bool {
matches!(
error,
FormatError::UnsupportedField { .. }
| FormatError::InvalidEnumValue { .. }
| FormatError::LossyConversionBlocked { .. }
| FormatError::InvalidTargetField { .. }
)
}
fn format_context_from_report_context(report_context: &Value) -> FormatContext {
let mut context = FormatContext::default().with_report_context(report_context.clone());
if let Some(mapped_model) = report_context
@@ -1427,12 +1461,15 @@ fn standard_same_format_api_format(report_kind: &str) -> Option<&'static str> {
}
}
fn aggregate_same_format_stream_sync_response(api_format: &str, body: &[u8]) -> Option<Value> {
fn try_aggregate_same_format_stream_sync_response(
api_format: &str,
body: &[u8],
) -> Result<Option<Value>, AiSurfaceFinalizeError> {
match api_format {
"openai:chat" => aggregate_openai_chat_stream_sync_response(body),
"claude:messages" => aggregate_claude_stream_sync_response(body),
"gemini:generate_content" => aggregate_gemini_stream_sync_response(body),
_ => None,
"openai:chat" => try_aggregate_openai_chat_stream_sync_response(body),
"claude:messages" => try_aggregate_claude_stream_sync_response(body),
"gemini:generate_content" => try_aggregate_gemini_stream_sync_response(body),
_ => Ok(None),
}
}
@@ -1502,6 +1539,58 @@ fn parse_stream_json_events(body: &[u8]) -> Option<Vec<Value>> {
Some(events)
}
fn try_aggregate_openai_chat_stream_sync_response(
body: &[u8],
) -> Result<Option<Value>, AiSurfaceFinalizeError> {
let report_context = Value::Object(Map::new());
let mut provider = OpenAIChatProviderState::default();
ensure_no_unknown_provider_stream_events(body, |line| {
provider.push_line(&report_context, line)
})?;
Ok(aggregate_openai_chat_stream_sync_response(body))
}
fn try_aggregate_openai_responses_stream_sync_response(
body: &[u8],
) -> Result<Option<Value>, AiSurfaceFinalizeError> {
let report_context = Value::Object(Map::new());
let mut provider = OpenAIResponsesProviderState::default();
ensure_no_unknown_provider_stream_events(body, |line| {
provider.push_line(&report_context, line)
})?;
Ok(aggregate_openai_responses_stream_sync_response(body))
}
fn try_aggregate_claude_stream_sync_response(
body: &[u8],
) -> Result<Option<Value>, AiSurfaceFinalizeError> {
let report_context = Value::Object(Map::new());
let mut provider = ClaudeProviderState::default();
ensure_no_unknown_provider_stream_events(body, |line| {
provider.push_line(&report_context, line)
})?;
Ok(aggregate_claude_stream_sync_response(body))
}
fn ensure_no_unknown_provider_stream_events(
body: &[u8],
mut push_line: impl FnMut(Vec<u8>) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError>,
) -> Result<(), AiSurfaceFinalizeError> {
let Ok(text) = std::str::from_utf8(body) else {
return Ok(());
};
for raw_line in text.lines() {
let frames = push_line(raw_line.as_bytes().to_vec())?;
if frames
.iter()
.any(|frame| matches!(frame.event, CanonicalStreamEvent::UnknownEvent(_)))
{
return Err(unsupported_stream_event_finalize_error());
}
}
Ok(())
}
pub fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option<Value> {
let text = std::str::from_utf8(body).ok()?;
let mut response_id: Option<String> = None;
@@ -2622,9 +2711,19 @@ pub fn aggregate_claude_stream_sync_response(body: &[u8]) -> Option<Value> {
}
pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
let events = parse_stream_json_events(body)?;
try_aggregate_gemini_stream_sync_response(body)
.ok()
.flatten()
}
fn try_aggregate_gemini_stream_sync_response(
body: &[u8],
) -> Result<Option<Value>, AiSurfaceFinalizeError> {
let Some(events) = parse_stream_json_events(body) else {
return Ok(None);
};
if events.is_empty() {
return None;
return Ok(None);
}
let report_context = Value::Object(Map::new());
@@ -2643,7 +2742,9 @@ pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
let mut usage_from_frames: Option<CanonicalUsage> = None;
for event in &events {
let raw_event_object = event.as_object()?;
let Some(raw_event_object) = event.as_object() else {
return Ok(None);
};
if let Some(id) = raw_event_object.get("responseId") {
response_id = Some(id.clone());
}
@@ -2696,7 +2797,7 @@ pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
}
let line = format!("data: {event}\n").into_bytes();
let frames = provider.push_line(&report_context, line).ok()?;
let frames = provider.push_line(&report_context, line)?;
for frame in frames {
if response_id.is_none() && !frame.id.is_empty() {
response_id = Some(Value::String(frame.id.clone()));
@@ -2774,7 +2875,9 @@ pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
content,
));
}
CanonicalStreamEvent::UnknownEvent(_) => {}
CanonicalStreamEvent::UnknownEvent(_) => {
return Err(unsupported_stream_event_finalize_error())
}
CanonicalStreamEvent::ReasoningSummaryDone => {}
CanonicalStreamEvent::Finish {
finish_reason: frame_finish_reason,
@@ -2793,7 +2896,7 @@ pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
}
}
let frames = provider.finish(&report_context).ok()?;
let frames = provider.finish(&report_context)?;
for frame in frames {
if response_id.is_none() && !frame.id.is_empty() {
response_id = Some(Value::String(frame.id.clone()));
@@ -2801,22 +2904,29 @@ pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
if model_version.is_none() && !frame.model.is_empty() {
model_version = Some(Value::String(frame.model.clone()));
}
if let CanonicalStreamEvent::Finish {
finish_reason: frame_finish_reason,
usage,
} = frame.event
{
finish_reason = frame_finish_reason
.map(|value| map_openai_finish_reason_to_gemini(Some(value.as_str())).to_string())
.or(finish_reason);
if usage.is_some() {
usage_from_frames = usage;
match frame.event {
CanonicalStreamEvent::UnknownEvent(_) => {
return Err(unsupported_stream_event_finalize_error())
}
CanonicalStreamEvent::Finish {
finish_reason: frame_finish_reason,
usage,
} => {
finish_reason = frame_finish_reason
.map(|value| {
map_openai_finish_reason_to_gemini(Some(value.as_str())).to_string()
})
.or(finish_reason);
if usage.is_some() {
usage_from_frames = usage;
}
}
_ => {}
}
}
if !saw_candidate {
return None;
return Ok(None);
}
candidate.insert(
@@ -2856,7 +2966,11 @@ pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
if let Some(prompt) = prompt_feedback {
response.insert("promptFeedback".to_string(), prompt);
}
Some(Value::Object(response))
Ok(Some(Value::Object(response)))
}
fn unsupported_stream_event_finalize_error() -> AiSurfaceFinalizeError {
AiSurfaceFinalizeError::new("Unsupported provider stream event cannot be converted losslessly")
}
fn append_gemini_text_part(parts: &mut Vec<Value>, text: String, thought: bool) {
@@ -3084,6 +3198,7 @@ fn guess_media_type_from_reference(reference: &str, default_mime: &str) -> Strin
mod tests {
use super::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
aggregate_openai_chat_stream_sync_response,
aggregate_openai_responses_stream_sync_response, convert_standard_chat_response,
convert_standard_cli_response,
maybe_build_openai_chat_cross_format_sync_product_from_normalized_payload,
@@ -3105,6 +3220,41 @@ mod tests {
use base64::Engine as _;
use serde_json::json;
#[test]
fn aggregates_openai_chat_stream_tool_usage_and_finish_into_sync_body() {
let body = concat!(
"data: {\"id\":\"chatcmpl_stream_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello \"}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_123\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"q\\\"\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\":\\\"rust\\\"}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n",
);
let result = aggregate_openai_chat_stream_sync_response(body.as_bytes())
.expect("openai chat stream should aggregate into a sync body");
assert_eq!(result["id"], "chatcmpl_stream_123");
assert_eq!(result["model"], "gpt-5");
assert_eq!(result["choices"][0]["message"]["role"], "assistant");
assert_eq!(result["choices"][0]["message"]["content"], "Hello ");
assert_eq!(
result["choices"][0]["message"]["tool_calls"][0]["id"],
"call_123"
);
assert_eq!(
result["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
"lookup"
);
assert_eq!(
result["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
"{\"q\":\"rust\"}"
);
assert_eq!(result["choices"][0]["finish_reason"], "tool_calls");
assert_eq!(result["usage"]["prompt_tokens"], 1);
assert_eq!(result["usage"]["completion_tokens"], 2);
assert_eq!(result["usage"]["total_tokens"], 3);
}
#[test]
fn aggregates_claude_stream_thinking_signatures_into_sync_body() {
let body = concat!(
@@ -3243,6 +3393,56 @@ mod tests {
assert_eq!(aggregated["usageMetadata"]["totalTokenCount"], 5);
}
#[test]
fn gemini_stream_aggregation_rejects_unknown_parts() {
let body = concat!(
"data: {\"responseId\":\"resp_gem_unknown_123\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"index\":0,\"content\":{\"role\":\"model\",\"parts\":[{\"futurePart\":{\"kept\":true}}]}}]}\n\n",
);
assert!(
aggregate_gemini_stream_sync_response(body.as_bytes()).is_none(),
"unknown Gemini stream parts must not be silently aggregated into a successful sync body"
);
}
#[test]
fn gemini_stream_finalize_rejects_unknown_parts_even_with_json_fallback() {
let report_context = json!({
"provider_api_format": "gemini:generate_content",
"client_api_format": "openai:chat",
});
let stream_body = concat!(
"data: {\"responseId\":\"resp_gem_unknown_456\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"index\":0,\"content\":{\"role\":\"model\",\"parts\":[{\"futurePart\":{\"kept\":true}}]}}]}\n\n",
);
let provider_body_json = json!({
"responseId": "resp_gem_unknown_456",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"index": 0,
"content": {
"role": "model",
"parts": [{
"text": "fallback"
}]
}
}]
});
let result = maybe_build_standard_cross_format_sync_product_from_normalized_payload(
"openai_chat_sync_finalize",
200,
Some(&report_context),
Some(&provider_body_json),
Some(&base64::engine::general_purpose::STANDARD.encode(stream_body)),
);
assert!(result.is_err());
let error = result.expect_err("unknown Gemini stream parts should fail closed");
assert!(error
.to_string()
.contains("Unsupported provider stream event cannot be converted losslessly"));
}
#[test]
fn aggregates_gemini_stream_function_response_into_sync_body() {
let body = concat!(
@@ -4566,6 +4766,176 @@ mod tests {
assert!(product.is_none());
}
#[test]
fn strict_response_conversion_errors_do_not_use_legacy_fallback() {
let openai_context = json!({
"provider_api_format": "openai:chat",
"client_api_format": "claude:messages",
"model": "claude-sonnet-4-5",
"mapped_model": "gpt-5",
});
let openai_body = json!({
"id": "chatcmpl_unknown_finish",
"object": "chat.completion",
"model": "gpt-5",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "done"
},
"finish_reason": "future_reason"
}]
});
assert!(convert_standard_chat_response(
&openai_body,
"openai:chat",
"claude:messages",
&openai_context,
)
.is_none());
let claude_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "openai:chat",
"model": "gpt-5",
"mapped_model": "claude-sonnet-4-5",
});
let claude_body = json!({
"id": "msg_unknown_stop",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [],
"stop_reason": "future_reason",
"usage": {
"input_tokens": 1,
"output_tokens": 2
}
});
assert!(convert_standard_chat_response(
&claude_body,
"claude:messages",
"openai:chat",
&claude_context,
)
.is_none());
}
#[test]
fn stream_finalize_rejects_unknown_openai_chat_events_without_body_fallback() {
let report_context = json!({
"provider_api_format": "openai:chat",
"client_api_format": "claude:messages",
"model": "claude-sonnet-4-5",
"mapped_model": "gpt-5",
});
let stream_body = concat!(
"data: {\"id\":\"chatcmpl_unknown_stream\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"future_delta\":\"x\"}}]}\n\n",
);
let fallback_body = json!({
"id": "chatcmpl_fallback",
"object": "chat.completion",
"model": "gpt-5",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "fallback"},
"finish_reason": "stop"
}]
});
let error = maybe_build_standard_cross_format_sync_product_from_normalized_payload(
"claude_chat_sync_finalize",
200,
Some(&report_context),
Some(&fallback_body),
Some(&base64::engine::general_purpose::STANDARD.encode(stream_body)),
)
.expect_err("unknown stream event should fail closed before body fallback");
assert!(error
.to_string()
.contains("Unsupported provider stream event cannot be converted losslessly"));
}
#[test]
fn stream_finalize_rejects_unknown_claude_events_without_body_fallback() {
let report_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "openai:chat",
"model": "gpt-5",
"mapped_model": "claude-sonnet-4-5",
});
let stream_body = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_unknown_stream\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null}}\n\n",
"event: future_event\n",
"data: {\"type\":\"future_event\",\"payload\":{\"kept\":true}}\n\n",
);
let fallback_body = json!({
"id": "msg_fallback",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [{"type": "text", "text": "fallback"}],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 1,
"output_tokens": 2
}
});
let error = maybe_build_standard_cross_format_sync_product_from_normalized_payload(
"openai_chat_sync_finalize",
200,
Some(&report_context),
Some(&fallback_body),
Some(&base64::engine::general_purpose::STANDARD.encode(stream_body)),
)
.expect_err("unknown stream event should fail closed before body fallback");
assert!(error
.to_string()
.contains("Unsupported provider stream event cannot be converted losslessly"));
}
#[test]
fn responses_same_family_stream_finalize_rejects_unknown_events_without_body_fallback() {
let report_context = json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
"needs_conversion": false,
"model": "gpt-5",
"mapped_model": "gpt-5",
});
let stream_body = concat!(
"event: response.future.delta\n",
"data: {\"type\":\"response.future.delta\",\"response\":{\"id\":\"resp_unknown_stream\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"in_progress\"},\"payload\":{\"kept\":true}}\n\n",
);
let fallback_body = json!({
"id": "resp_fallback",
"object": "response",
"model": "gpt-5",
"status": "completed",
"output": []
});
let error = maybe_build_openai_responses_same_family_sync_body_from_normalized_payload(
"openai_responses_sync_finalize",
200,
Some(&report_context),
Some(&fallback_body),
Some(&base64::engine::general_purpose::STANDARD.encode(stream_body)),
)
.expect_err("unknown stream event should fail closed before body fallback");
assert!(error
.to_string()
.contains("Unsupported provider stream event cannot be converted losslessly"));
}
#[test]
fn standard_sync_finalize_product_prefers_same_format_success_body() {
let report_context = json!({
+9 -2
View File
@@ -6,7 +6,10 @@ pub mod formats;
pub mod protocol;
pub mod provider_compat;
pub use formats::context::{FormatContext, FormatError};
pub use formats::context::{
ConversionFieldRecord, ConversionFieldStatus, ConversionReport, Converted, FormatContext,
FormatError,
};
pub use formats::id::{
api_format_alias_matches, api_format_storage_aliases, api_format_uses_body_stream_field,
is_openai_responses_compact_format, is_openai_responses_family_format,
@@ -19,7 +22,11 @@ pub use formats::matrix::{
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
SyncCliResponseConversionKind,
};
pub use formats::registry::{build_stream_transcoder, convert_request, convert_response};
pub use formats::registry::{
build_stream_transcoder, convert_request, convert_request_pure,
convert_request_pure_with_context, convert_response, convert_response_pure, emit_request_pure,
emit_response_pure, parse_request_pure, parse_response_pure,
};
pub use formats::shared::model_directives::{
apply_model_directive_mapping_patch, apply_model_directive_overrides_from_model,
apply_model_directive_overrides_from_request, claude_model_uses_adaptive_effort,
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use crate::formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort;
use crate::formats::shared::model_directives::ReasoningEffort;
use crate::formats::shared::response::remove_empty_pages_from_tool_input_value;
pub use crate::protocol::stream::{CanonicalStreamEvent, CanonicalStreamFrame};
@@ -186,6 +187,8 @@ pub struct CanonicalToolDefinition {
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parameters: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extensions: BTreeMap<String, Value>,
}
@@ -3209,10 +3212,18 @@ pub(crate) fn claude_tools_to_canonical(
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: tool_object.get("input_schema").cloned(),
extensions: claude_extensions(
tool_object,
&["type", "name", "description", "input_schema"],
),
strict: None,
extensions: {
let mut extensions = claude_extensions(
tool_object,
&["type", "name", "description", "input_schema"],
);
if let Some(input_schema) = tool_object.get("input_schema").cloned() {
canonical_extension_object_mut(&mut extensions, "claude")
.insert("raw_input_schema".to_string(), input_schema);
}
extensions
},
});
}
Some((canonical, builtin_tools, web_search_options))
@@ -3361,14 +3372,7 @@ pub(crate) fn claude_thinking_to_canonical(
}
pub(crate) fn claude_output_effort_to_openai_reasoning_effort(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
"xhigh" => Some("xhigh"),
"max" => Some("max"),
_ => None,
}
ReasoningEffort::parse(value).map(ReasoningEffort::as_openai_chat_value)
}
pub(crate) fn canonical_openai_reasoning_effort(
@@ -3416,13 +3420,20 @@ pub(crate) fn gemini_thinking_to_canonical(
.get("thinkingBudget")
.or_else(|| thinking_config.get("thinking_budget"))
.and_then(Value::as_u64);
let thinking_level = thinking_config
.get("thinkingLevel")
.or_else(|| thinking_config.get("thinking_level"))
.and_then(Value::as_str)
.and_then(ReasoningEffort::parse)
.map(ReasoningEffort::as_openai_chat_value);
let mut extensions = BTreeMap::new();
extensions.insert(
"gemini".to_string(),
json!({ "thinking_config": Value::Object(thinking_config.clone()) }),
);
if let Some(reasoning_effort) =
budget_tokens.map(map_thinking_budget_to_openai_reasoning_effort)
if let Some(reasoning_effort) = budget_tokens
.map(map_thinking_budget_to_openai_reasoning_effort)
.or(thinking_level)
{
extensions.insert(
"openai".to_string(),
@@ -3625,10 +3636,18 @@ pub(crate) fn gemini_tools_to_canonical(value: Option<&Value>) -> Option<GeminiC
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: declaration_object.get("parameters").cloned(),
extensions: gemini_extensions(
declaration_object,
&["name", "description", "parameters"],
),
strict: None,
extensions: {
let mut extensions = gemini_extensions(
declaration_object,
&["name", "description", "parameters"],
);
if let Some(parameters) = declaration_object.get("parameters").cloned() {
canonical_extension_object_mut(&mut extensions, "gemini")
.insert("raw_parameters".to_string(), parameters);
}
extensions
},
});
}
}
@@ -3761,6 +3780,7 @@ pub(crate) fn openai_tools_to_canonical(
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: function.get("parameters").cloned(),
strict: function.get("strict").and_then(Value::as_bool),
extensions: openai_extensions(tool_object, &["type", "function"]),
})
})
@@ -3792,8 +3812,10 @@ pub(crate) fn openai_responses_tools_to_canonical(
.filter(|value| !value.is_empty())?;
let mut extensions =
openai_responses_extensions(tool_object, &["type", "function"]);
let function_extensions =
openai_responses_extensions(function, &["name", "description", "parameters"]);
let function_extensions = openai_responses_extensions(
function,
&["name", "description", "parameters", "strict"],
);
merge_tool_extensions(&mut extensions, function_extensions);
canonical.push(CanonicalToolDefinition {
name: name.to_string(),
@@ -3802,6 +3824,7 @@ pub(crate) fn openai_responses_tools_to_canonical(
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: function.get("parameters").cloned(),
strict: function.get("strict").and_then(Value::as_bool),
extensions,
});
continue;
@@ -3818,9 +3841,10 @@ pub(crate) fn openai_responses_tools_to_canonical(
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: tool_object.get("parameters").cloned(),
strict: tool_object.get("strict").and_then(Value::as_bool),
extensions: openai_responses_extensions(
tool_object,
&["type", "name", "description", "parameters"],
&["type", "name", "description", "parameters", "strict"],
),
});
} else if tool_type == "custom" {
@@ -3853,6 +3877,7 @@ pub(crate) fn openai_responses_tools_to_canonical(
name: name.to_string(),
description,
parameters,
strict: tool_object.get("strict").and_then(Value::as_bool),
extensions: BTreeMap::from([(
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
tool.clone(),
@@ -3863,6 +3888,7 @@ pub(crate) fn openai_responses_tools_to_canonical(
name: tool_type,
description: None,
parameters: None,
strict: None,
extensions: BTreeMap::from([(
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
tool.clone(),
@@ -3898,6 +3924,9 @@ pub(crate) fn canonical_tool_to_openai(tool: &CanonicalToolDefinition) -> Value
if let Some(parameters) = &tool.parameters {
function.insert("parameters".to_string(), parameters.clone());
}
if let Some(strict) = tool.strict {
function.insert("strict".to_string(), Value::Bool(strict));
}
json!({
"type": "function",
"function": Value::Object(function),
@@ -4428,9 +4457,18 @@ pub(crate) fn canonical_tools_to_claude(canonical: &CanonicalRequest) -> Vec<Val
}
out.insert(
"input_schema".to_string(),
claude_input_schema_from_tool_parameters(tool.parameters.as_ref()),
tool.extensions
.get("claude")
.and_then(Value::as_object)
.and_then(|value| value.get("raw_input_schema"))
.cloned()
.unwrap_or_else(|| {
claude_input_schema_from_tool_parameters(tool.parameters.as_ref())
}),
);
out.extend(namespace_extension_object(&tool.extensions, "claude", &out));
let mut extra = namespace_extension_object(&tool.extensions, "claude", &out);
extra.remove("raw_input_schema");
out.extend(extra);
Value::Object(out)
})
.collect::<Vec<_>>();
@@ -4912,9 +4950,15 @@ pub(crate) fn gemini_stop_reason_to_canonical(value: &str) -> Option<CanonicalSt
Some(match value.trim().to_ascii_uppercase().as_str() {
"STOP" => CanonicalStopReason::EndTurn,
"MAX_TOKENS" => CanonicalStopReason::MaxTokens,
"SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII" => {
CanonicalStopReason::ContentFiltered
}
"SAFETY"
| "RECITATION"
| "LANGUAGE"
| "BLOCKLIST"
| "PROHIBITED_CONTENT"
| "SPII"
| "IMAGE_SAFETY"
| "IMAGE_PROHIBITED_CONTENT"
| "IMAGE_RECITATION" => CanonicalStopReason::ContentFiltered,
"OTHER" => CanonicalStopReason::Unknown,
_ => CanonicalStopReason::Unknown,
})