mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 16:37:46 +08:00
feat: audit strict format conversion contracts
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user