mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
Merge pull request #859 from stabey/fix/protocol-conversion-live-fixes
fix(formats): repair live cross-format conversion gaps
This commit is contained in:
@@ -196,6 +196,78 @@ fn maybe_build_invalid_provider_success_finalize_response(
|
||||
)?))
|
||||
}
|
||||
|
||||
fn local_sync_needs_conversion(payload: &GatewaySyncReportRequest) -> bool {
|
||||
payload
|
||||
.report_context
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("needs_conversion"))
|
||||
.and_then(|value| value.as_bool())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A successful upstream response that needed conversion but could not be
|
||||
/// converted must not reach the client in the provider's own format.
|
||||
fn maybe_build_unconverted_cross_format_success_response(
|
||||
trace_id: &str,
|
||||
decision: &GatewayControlDecision,
|
||||
payload: &GatewaySyncReportRequest,
|
||||
) -> Result<Option<Response<Body>>, GatewayError> {
|
||||
if payload.status_code >= 400
|
||||
|| !local_sync_needs_conversion(payload)
|
||||
|| !is_core_error_finalize_kind(payload.report_kind.as_str())
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let client_api_format = resolve_local_sync_client_api_format(payload);
|
||||
let provider_api_format = resolve_local_sync_provider_api_format(payload);
|
||||
warn!(
|
||||
event_name = "local_core_finalize_cross_format_success_unconverted",
|
||||
log_type = "event",
|
||||
trace_id = %trace_id,
|
||||
report_kind = %payload.report_kind,
|
||||
status_code = payload.status_code,
|
||||
client_api_format = %client_api_format,
|
||||
provider_api_format = %provider_api_format,
|
||||
"gateway could not convert a successful provider response to the client format"
|
||||
);
|
||||
let message = format!(
|
||||
"Provider returned HTTP {} but its {provider_api_format} response could not be converted to {client_api_format}.",
|
||||
payload.status_code
|
||||
);
|
||||
let body_json = build_core_error_body_for_client_format(
|
||||
&client_api_format,
|
||||
&message,
|
||||
Some("response_conversion_failed"),
|
||||
LocalCoreSyncErrorKind::ServerError,
|
||||
)
|
||||
.unwrap_or_else(|| {
|
||||
serde_json::json!({
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": "server_error",
|
||||
"code": "response_conversion_failed"
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
let mut response_headers = payload.headers.clone();
|
||||
response_headers.remove("content-encoding");
|
||||
response_headers.remove("content-length");
|
||||
response_headers.insert("content-type".to_string(), "application/json".to_string());
|
||||
let body_bytes =
|
||||
serde_json::to_vec(&body_json).map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
response_headers.insert("content-length".to_string(), body_bytes.len().to_string());
|
||||
|
||||
Ok(Some(build_client_response_from_parts(
|
||||
StatusCode::BAD_GATEWAY.as_u16(),
|
||||
&response_headers,
|
||||
Body::from(body_bytes),
|
||||
trace_id,
|
||||
Some(decision),
|
||||
)?))
|
||||
}
|
||||
|
||||
fn local_core_sync_finalize_has_invalid_provider_success(
|
||||
payload: &GatewaySyncReportRequest,
|
||||
) -> Result<bool, GatewayError> {
|
||||
@@ -274,6 +346,12 @@ pub(crate) fn resolve_local_core_error_response_body_json(
|
||||
return Ok(Some(body_json));
|
||||
}
|
||||
|
||||
// A 2xx cross-format body that is not JSON (e.g. an aggregated SSE capture)
|
||||
// carries no upstream error; wrapping it as one would ship raw provider
|
||||
// bytes to the client under the success status.
|
||||
if payload.status_code < 400 && local_sync_needs_conversion(payload) {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(body_text) = decode_local_sync_body_text(payload)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -626,6 +704,10 @@ pub(crate) async fn submit_local_core_error_or_sync_finalize(
|
||||
maybe_build_local_core_error_response(trace_id, decision, &payload)?
|
||||
{
|
||||
response
|
||||
} else if let Some(response) =
|
||||
maybe_build_unconverted_cross_format_success_response(trace_id, decision, &payload)?
|
||||
{
|
||||
response
|
||||
} else {
|
||||
warn!(
|
||||
event_name = "local_core_finalize_fallback_raw_response_body",
|
||||
@@ -937,6 +1019,128 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_core_sync_finalize_converts_forced_responses_stream_for_gemini_client() {
|
||||
use base64::Engine as _;
|
||||
|
||||
// Forced-stream xAI shape: the terminal response echoes request
|
||||
// metadata and encrypted reasoning next to the real answer.
|
||||
let raw_sse = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"resp_xai_123\",\"object\":\"response\",\"status\":\"in_progress\",\"model\":\"grok-4.7-build\",\"output\":[],\"parallel_tool_calls\":true,\"tools\":[]}}\n\n",
|
||||
"event: response.output_item.done\n",
|
||||
"data: {\"type\":\"response.output_item.done\",\"sequence_number\":1,\"output_index\":0,\"item\":{\"id\":\"rs_xai_123\",\"type\":\"reasoning\",\"status\":\"completed\",\"summary\":[],\"encrypted_content\":\"opaque-xai-reasoning\"}}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"sequence_number\":2,\"item_id\":\"msg_xai_123\",\"output_index\":1,\"content_index\":0,\"delta\":\"Hi there, friend\"}\n\n",
|
||||
"event: response.output_item.done\n",
|
||||
"data: {\"type\":\"response.output_item.done\",\"sequence_number\":3,\"output_index\":1,\"item\":{\"id\":\"msg_xai_123\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hi there, friend\",\"annotations\":[]}]}}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"sequence_number\":4,\"response\":{\"id\":\"resp_xai_123\",\"object\":\"response\",\"status\":\"completed\",\"model\":\"grok-4.7-build\",\"output\":[],\"parallel_tool_calls\":true,\"tool_choice\":\"auto\",\"tools\":[],\"text\":{\"format\":{\"type\":\"text\"}},\"temperature\":0.7,\"store\":false,\"usage\":{\"input_tokens\":1249,\"output_tokens\":12,\"total_tokens\":1261}}}\n\n",
|
||||
);
|
||||
let mut payload = core_finalize_payload(
|
||||
"gemini_chat_sync_finalize",
|
||||
"gemini:generate_content",
|
||||
"openai:responses",
|
||||
200,
|
||||
json!(null),
|
||||
);
|
||||
payload.body_json = None;
|
||||
payload.body_base64 = Some(base64::engine::general_purpose::STANDARD.encode(raw_sse));
|
||||
payload.report_context = Some(json!({
|
||||
"client_api_format": "gemini:generate_content",
|
||||
"provider_api_format": "openai:responses",
|
||||
"provider_stream_event_api_format": "openai:responses",
|
||||
"model": "grok-4.7",
|
||||
"mapped_model": "grok-4.7",
|
||||
"needs_conversion": true,
|
||||
}));
|
||||
|
||||
let state = AppState::new().expect("state should build");
|
||||
let response = submit_local_core_error_or_sync_finalize(
|
||||
&state,
|
||||
"trace-forced-responses-gemini",
|
||||
&test_decision(),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
.expect("finalize should build a response");
|
||||
|
||||
assert_eq!(response.status(), http::StatusCode::OK);
|
||||
let body_bytes = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read");
|
||||
let body =
|
||||
serde_json::from_slice::<serde_json::Value>(&body_bytes).expect("body should decode");
|
||||
assert!(body.get("error").is_none(), "unexpected error body: {body}");
|
||||
let parts = body["candidates"][0]["content"]["parts"]
|
||||
.as_array()
|
||||
.expect("gemini parts");
|
||||
assert!(parts.iter().any(|part| part["text"] == "Hi there, friend"));
|
||||
let text = String::from_utf8_lossy(&body_bytes);
|
||||
assert!(!text.contains("opaque-xai-reasoning") && !text.contains("response.created"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_core_sync_finalize_never_wraps_unconvertible_success_sse_as_client_error() {
|
||||
use base64::Engine as _;
|
||||
|
||||
// A complete stream whose output the Gemini client cannot represent.
|
||||
let raw_sse = concat!(
|
||||
"event: response.output_item.done\n",
|
||||
"data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"id\":\"future_item_123\",\"type\":\"future_output\",\"payload\":\"must-not-drop\"}}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_raw_123\",\"object\":\"response\",\"status\":\"completed\",\"model\":\"grok-4.7\",\"output\":[]}}\n\n",
|
||||
);
|
||||
let mut payload = core_finalize_payload(
|
||||
"gemini_chat_sync_finalize",
|
||||
"gemini:generate_content",
|
||||
"openai:responses",
|
||||
200,
|
||||
json!(null),
|
||||
);
|
||||
payload.body_json = None;
|
||||
payload.body_base64 = Some(base64::engine::general_purpose::STANDARD.encode(raw_sse));
|
||||
payload.report_context = Some(json!({
|
||||
"client_api_format": "gemini:generate_content",
|
||||
"provider_api_format": "openai:responses",
|
||||
"provider_stream_event_api_format": "openai:responses",
|
||||
"needs_conversion": true,
|
||||
}));
|
||||
|
||||
assert!(maybe_build_local_core_error_response(
|
||||
"trace-raw-success-sse",
|
||||
&test_decision(),
|
||||
&payload,
|
||||
)
|
||||
.expect("response build should not error")
|
||||
.is_none());
|
||||
|
||||
let state = AppState::new().expect("state should build");
|
||||
let response = submit_local_core_error_or_sync_finalize(
|
||||
&state,
|
||||
"trace-raw-success-sse",
|
||||
&test_decision(),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
.expect("finalize should build a response");
|
||||
|
||||
assert_eq!(response.status(), http::StatusCode::BAD_GATEWAY);
|
||||
let body = serde_json::from_slice::<serde_json::Value>(
|
||||
&to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read"),
|
||||
)
|
||||
.expect("body should decode");
|
||||
let message = body["error"]["message"]
|
||||
.as_str()
|
||||
.expect("error message should exist");
|
||||
assert!(
|
||||
message.contains("could not be converted") && !message.contains("must-not-drop"),
|
||||
"unexpected message: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_local_core_finalize_keeps_http_200_for_success_image_body() {
|
||||
let payload = core_finalize_payload(
|
||||
|
||||
@@ -893,8 +893,8 @@ async fn gateway_executes_openai_responses_cross_format_function_call_upstream_s
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": "call_auto_1",
|
||||
"call_id": "call_auto_1",
|
||||
"id": "call_auto_0",
|
||||
"call_id": "call_auto_0",
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"location\":\"Tokyo\"}"
|
||||
}
|
||||
|
||||
@@ -323,6 +323,12 @@ impl GeminiProviderState {
|
||||
self.observed_tool_calls = true;
|
||||
continue;
|
||||
}
|
||||
// Gemini streams are incremental and every functionCall part is a
|
||||
// complete call, so parallel calls arriving in separate chunks all
|
||||
// sit at parts[0]. Key calls by arrival order, not part position.
|
||||
// Ids cannot disambiguate: they are optional, and the Antigravity
|
||||
// envelope synthesizes per-chunk ids that repeat across chunks.
|
||||
let index = self.tool_calls.len();
|
||||
let tool_state = self.tool_calls.entry(index).or_default();
|
||||
tool_state.call_id = function_call
|
||||
.get("id")
|
||||
@@ -1526,6 +1532,80 @@ mod tests {
|
||||
assert!(signature_index < call_index);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_provider_state_keeps_parallel_function_calls_from_separate_chunks() {
|
||||
let mut state = GeminiProviderState::default();
|
||||
let report_context = json!({});
|
||||
let chunk = |call: Value| {
|
||||
data_line(json!({
|
||||
"responseId": "resp_parallel_123",
|
||||
"modelVersion": "gemini-3.8-flash",
|
||||
"candidates": [{
|
||||
"index": 0,
|
||||
"content": {"role": "model", "parts": [{"functionCall": call}]}
|
||||
}]
|
||||
}))
|
||||
};
|
||||
let mut frames = Vec::new();
|
||||
for call in [
|
||||
json!({"id": "call_a", "name": "get_weather", "args": {"city": "Paris"}}),
|
||||
json!({"id": "call_b", "name": "get_weather", "args": {"city": "Tokyo"}}),
|
||||
json!({"name": "get_time", "args": {"city": "Paris"}}),
|
||||
json!({"name": "get_time", "args": {"city": "Tokyo"}}),
|
||||
] {
|
||||
frames.extend(
|
||||
state
|
||||
.push_line(&report_context, chunk(call))
|
||||
.expect("function call chunk should parse"),
|
||||
);
|
||||
}
|
||||
|
||||
let starts = frames
|
||||
.iter()
|
||||
.filter_map(|frame| match &frame.event {
|
||||
CanonicalStreamEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
name,
|
||||
} => Some((*index, call_id.clone(), name.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(starts.len(), 4);
|
||||
assert_eq!(
|
||||
starts
|
||||
.iter()
|
||||
.map(|(index, _, _)| *index)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![0, 1, 2, 3]
|
||||
);
|
||||
assert_eq!(starts[0].1, "call_a");
|
||||
assert_eq!(starts[1].1, "call_b");
|
||||
assert_eq!(
|
||||
starts
|
||||
.iter()
|
||||
.map(|(_, _, name)| name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["get_weather", "get_weather", "get_time", "get_time"]
|
||||
);
|
||||
assert_ne!(starts[2].1, starts[3].1);
|
||||
|
||||
let mut arguments = BTreeMap::<usize, String>::new();
|
||||
for frame in &frames {
|
||||
if let CanonicalStreamEvent::ToolCallArgumentsDelta {
|
||||
index,
|
||||
arguments: delta,
|
||||
} = &frame.event
|
||||
{
|
||||
arguments.entry(*index).or_default().push_str(delta);
|
||||
}
|
||||
}
|
||||
assert_eq!(arguments[&0], "{\"city\":\"Paris\"}");
|
||||
assert_eq!(arguments[&1], "{\"city\":\"Tokyo\"}");
|
||||
assert_eq!(arguments[&2], "{\"city\":\"Paris\"}");
|
||||
assert_eq!(arguments[&3], "{\"city\":\"Tokyo\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_client_emitter_marks_reasoning_parts_as_thoughts() {
|
||||
let mut emitter = GeminiClientEmitter::default();
|
||||
|
||||
@@ -114,6 +114,14 @@ fn openai_responses_incomplete_stop_reason(body: &Map<String, Value>) -> Canonic
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_incomplete_reason(canonical: &CanonicalResponse) -> Option<&'static str> {
|
||||
match canonical.stop_reason.as_ref()? {
|
||||
CanonicalStopReason::MaxTokens => Some("max_output_tokens"),
|
||||
CanonicalStopReason::ContentFiltered => Some("content_filter"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bool) -> Value {
|
||||
let namespace_tool_aliases = NamespaceToolAliases::from_report_context(report_context);
|
||||
let mut response = Map::new();
|
||||
@@ -143,6 +151,17 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo
|
||||
.cloned()
|
||||
{
|
||||
response.insert("status".to_string(), raw_status);
|
||||
} else if let Some(reason) = canonical_incomplete_reason(canonical) {
|
||||
// Cross-format sources carry no Responses status of their own; a
|
||||
// truncated or filtered answer must not be reported as completed.
|
||||
response.insert(
|
||||
"status".to_string(),
|
||||
Value::String("incomplete".to_string()),
|
||||
);
|
||||
response.insert(
|
||||
"incomplete_details".to_string(),
|
||||
json!({ "reason": reason }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,4 +950,73 @@ mod tests {
|
||||
}) if id == "call_ws_1" && name == "web_search" && input["query"] == "today tech")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_response_builder_reports_cross_format_truncation_as_incomplete() {
|
||||
let response = |stop_reason| CanonicalResponse {
|
||||
id: "gemini-resp".to_string(),
|
||||
model: "gemini-3.8-flash".to_string(),
|
||||
content: vec![CanonicalContentBlock::Text {
|
||||
text: "partial".to_string(),
|
||||
extensions: BTreeMap::new(),
|
||||
}],
|
||||
outputs: Vec::new(),
|
||||
stop_reason: Some(stop_reason),
|
||||
usage: None,
|
||||
extensions: BTreeMap::new(),
|
||||
};
|
||||
|
||||
let truncated = to_raw(&response(CanonicalStopReason::MaxTokens), &json!({}), false);
|
||||
assert_eq!(truncated["status"], "incomplete");
|
||||
assert_eq!(
|
||||
truncated["incomplete_details"],
|
||||
json!({"reason": "max_output_tokens"})
|
||||
);
|
||||
|
||||
let filtered = to_raw(
|
||||
&response(CanonicalStopReason::ContentFiltered),
|
||||
&json!({}),
|
||||
false,
|
||||
);
|
||||
assert_eq!(filtered["status"], "incomplete");
|
||||
assert_eq!(
|
||||
filtered["incomplete_details"],
|
||||
json!({"reason": "content_filter"})
|
||||
);
|
||||
|
||||
let finished = to_raw(&response(CanonicalStopReason::EndTurn), &json!({}), false);
|
||||
assert_eq!(finished["status"], "completed");
|
||||
assert!(finished.get("incomplete_details").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_max_tokens_response_converts_to_incomplete_responses_body() {
|
||||
let gemini = json!({
|
||||
"responseId": "gemini-trunc-123",
|
||||
"modelVersion": "gemini-3.8-flash",
|
||||
"candidates": [{
|
||||
"content": {"role": "model", "parts": [{"text": "The printing press"}]},
|
||||
"finishReason": "MAX_TOKENS"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 19,
|
||||
"candidatesTokenCount": 256,
|
||||
"totalTokenCount": 275
|
||||
}
|
||||
});
|
||||
|
||||
let body = crate::formats::registry::convert_response(
|
||||
"gemini:generate_content",
|
||||
"openai:responses",
|
||||
&gemini,
|
||||
&FormatContext::default(),
|
||||
)
|
||||
.expect("gemini response should convert");
|
||||
|
||||
assert_eq!(body["status"], "incomplete");
|
||||
assert_eq!(
|
||||
body["incomplete_details"],
|
||||
json!({"reason": "max_output_tokens"})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,11 +115,26 @@ pub fn maybe_build_standard_cross_format_sync_product_from_normalized_payload(
|
||||
.as_deref()
|
||||
.unwrap_or(provider_api_format);
|
||||
|
||||
let aggregated_from_stream = aggregated_stream_body.is_some();
|
||||
let Some(provider_body_json) = aggregated_stream_body.or_else(|| body_json.cloned()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let projection_fallback_body = aggregated_from_stream.then(|| provider_body_json.clone());
|
||||
|
||||
Ok(maybe_build_standard_cross_format_sync_product(
|
||||
let product = maybe_build_standard_cross_format_sync_product(
|
||||
report_kind,
|
||||
provider_body_api_format,
|
||||
client_api_format,
|
||||
report_context,
|
||||
provider_body_json,
|
||||
);
|
||||
if product.is_some() {
|
||||
return Ok(product);
|
||||
}
|
||||
let Some(provider_body_json) = projection_fallback_body else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(project_validated_openai_responses_stream_sync_product(
|
||||
report_kind,
|
||||
provider_body_api_format,
|
||||
client_api_format,
|
||||
@@ -128,6 +143,46 @@ pub fn maybe_build_standard_cross_format_sync_product_from_normalized_payload(
|
||||
))
|
||||
}
|
||||
|
||||
/// Forced-stream Responses upstreams (Codex, xAI) echo request metadata such as
|
||||
/// `parallel_tool_calls`, `tools` and encrypted reasoning back in the aggregated
|
||||
/// body, which the strict cross-format response check refuses. The aggregated
|
||||
/// body stays the provider body, so the client projection may drop those
|
||||
/// provider-only fields — mirroring the OpenAI Chat client path.
|
||||
fn project_validated_openai_responses_stream_sync_product(
|
||||
report_kind: &str,
|
||||
provider_api_format: &str,
|
||||
client_api_format: &str,
|
||||
report_context: &Value,
|
||||
provider_body_json: Value,
|
||||
) -> Option<StandardCrossFormatSyncProduct> {
|
||||
let provider_api_format = normalize_openai_responses_family_api_format(provider_api_format);
|
||||
if !matches!(
|
||||
provider_api_format.as_str(),
|
||||
"openai:responses" | "openai:responses:compact"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let client_api_format = client_api_format.trim().to_ascii_lowercase();
|
||||
if is_standard_chat_finalize_kind(report_kind) {
|
||||
sync_chat_response_conversion_kind(&provider_api_format, &client_api_format)?;
|
||||
} else if is_standard_cli_finalize_kind(report_kind) {
|
||||
sync_cli_response_conversion_kind(&provider_api_format, &client_api_format)?;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
let client_body_json = project_validated_openai_responses_stream_to_client(
|
||||
&provider_body_json,
|
||||
&client_api_format,
|
||||
report_context,
|
||||
)?;
|
||||
let client_body_json =
|
||||
client_body_with_report_context_model(client_body_json, report_context, &client_api_format);
|
||||
Some(StandardCrossFormatSyncProduct {
|
||||
client_body_json,
|
||||
provider_body_json,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn maybe_build_standard_same_format_sync_body_from_normalized_payload(
|
||||
report_kind: &str,
|
||||
status_code: u16,
|
||||
@@ -1474,6 +1529,14 @@ fn convert_openai_chat_canonical_response_to_openai_chat(
|
||||
fn project_validated_openai_responses_stream_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
project_validated_openai_responses_stream_to_client(body_json, "openai:chat", report_context)
|
||||
}
|
||||
|
||||
fn project_validated_openai_responses_stream_to_client(
|
||||
body_json: &Value,
|
||||
client_api_format: &str,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
// The caller retains body_json as provider_body_json. This projection is therefore allowed
|
||||
// to omit provider-only response metadata, but never unknown canonical output blocks.
|
||||
@@ -1490,7 +1553,12 @@ fn project_validated_openai_responses_stream_to_openai_chat(
|
||||
}
|
||||
|
||||
apply_report_context_model_fallback(&mut canonical.model, report_context);
|
||||
Some(canonical_to_openai_chat_response(&canonical))
|
||||
match client_api_format {
|
||||
"openai:chat" => Some(canonical_to_openai_chat_response(&canonical)),
|
||||
"claude:messages" => Some(canonical_to_claude_response(&canonical)),
|
||||
"gemini:generate_content" => canonical_to_gemini_response(&canonical, report_context),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_chat_response_can_use_single_response_canonical(body_json: &Value) -> bool {
|
||||
@@ -7143,6 +7211,84 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_sync_finalize_projects_forced_responses_stream_to_gemini_and_claude_clients() {
|
||||
// Shape of a forced-stream xAI / Codex upstream: the terminal response
|
||||
// echoes request metadata and carries encrypted reasoning, which the
|
||||
// strict cross-format check refuses.
|
||||
let stream_body = concat!(
|
||||
"data: {\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"resp_forced_123\",\"object\":\"response\",\"status\":\"in_progress\",\"model\":\"grok-4.7-build\",\"output\":[],\"parallel_tool_calls\":true,\"tool_choice\":\"auto\",\"tools\":[],\"temperature\":0.7}}\n\n",
|
||||
"data: {\"type\":\"response.output_item.done\",\"sequence_number\":1,\"output_index\":0,\"item\":{\"id\":\"rs_forced_123\",\"type\":\"reasoning\",\"status\":\"completed\",\"summary\":[{\"type\":\"summary_text\",\"text\":\"greet briefly\"}],\"encrypted_content\":\"opaque-xai-reasoning\"}}\n\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"sequence_number\":2,\"item_id\":\"msg_forced_123\",\"output_index\":1,\"content_index\":0,\"delta\":\"Hello there friend\"}\n\n",
|
||||
"data: {\"type\":\"response.output_item.done\",\"sequence_number\":3,\"output_index\":1,\"item\":{\"id\":\"msg_forced_123\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello there friend\",\"annotations\":[]}]}}\n\n",
|
||||
"data: {\"type\":\"response.output_item.done\",\"sequence_number\":4,\"output_index\":2,\"item\":{\"id\":\"fc_forced_123\",\"type\":\"function_call\",\"status\":\"completed\",\"call_id\":\"call_forced_123\",\"name\":\"search\",\"arguments\":\"{\\\"q\\\":\\\"aether\\\"}\"}}\n\n",
|
||||
"data: {\"type\":\"response.completed\",\"sequence_number\":5,\"response\":{\"id\":\"resp_forced_123\",\"object\":\"response\",\"status\":\"completed\",\"model\":\"grok-4.7-build\",\"output\":[],\"parallel_tool_calls\":true,\"tool_choice\":\"auto\",\"tools\":[{\"type\":\"function\",\"name\":\"search\",\"parameters\":{\"type\":\"object\"}}],\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":{\"effort\":null,\"summary\":null},\"temperature\":0.7,\"top_p\":0.95,\"store\":false,\"usage\":{\"input_tokens\":1249,\"input_tokens_details\":{\"cached_tokens\":1152},\"output_tokens\":40,\"output_tokens_details\":{\"reasoning_tokens\":31},\"total_tokens\":1289}}}\n\n",
|
||||
);
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(stream_body);
|
||||
|
||||
for (report_kind, client_api_format) in [
|
||||
("gemini_chat_sync_finalize", "gemini:generate_content"),
|
||||
("gemini_cli_sync_finalize", "gemini:generate_content"),
|
||||
("claude_chat_sync_finalize", "claude:messages"),
|
||||
("claude_cli_sync_finalize", "claude:messages"),
|
||||
] {
|
||||
let report_context = json!({
|
||||
"provider_api_format": "openai:responses",
|
||||
"provider_stream_event_api_format": "openai:responses",
|
||||
"client_api_format": client_api_format,
|
||||
"model": "grok-4.7",
|
||||
"mapped_model": "grok-4.7",
|
||||
"needs_conversion": true,
|
||||
});
|
||||
let product = maybe_build_standard_sync_finalize_product_from_normalized_payload(
|
||||
report_kind,
|
||||
200,
|
||||
Some(&report_context),
|
||||
None,
|
||||
Some(&encoded),
|
||||
)
|
||||
.expect("forced Responses stream should aggregate")
|
||||
.unwrap_or_else(|| panic!("{report_kind} should receive a projection"));
|
||||
let StandardSyncFinalizeNormalizedProduct::CrossFormat(product) = product else {
|
||||
panic!("{report_kind}: Responses stream should stay a cross-format product")
|
||||
};
|
||||
assert_eq!(product.provider_body_json["parallel_tool_calls"], true);
|
||||
let client = product.client_body_json.to_string();
|
||||
assert!(
|
||||
!client.contains("opaque-xai-reasoning") && !client.contains("response.created"),
|
||||
"{report_kind}: provider-only data leaked into the client body: {client}"
|
||||
);
|
||||
if client_api_format == "gemini:generate_content" {
|
||||
let parts = product.client_body_json["candidates"][0]["content"]["parts"]
|
||||
.as_array()
|
||||
.expect("gemini parts");
|
||||
assert!(parts
|
||||
.iter()
|
||||
.any(|part| part["text"] == "Hello there friend"
|
||||
&& part.get("thought").is_none()));
|
||||
assert!(parts
|
||||
.iter()
|
||||
.any(|part| part["functionCall"]["name"] == "search"
|
||||
&& part["functionCall"]["args"]["q"] == "aether"));
|
||||
assert_eq!(
|
||||
product.client_body_json["usageMetadata"]["promptTokenCount"],
|
||||
1249
|
||||
);
|
||||
} else {
|
||||
let content = product.client_body_json["content"]
|
||||
.as_array()
|
||||
.expect("claude content");
|
||||
assert!(content
|
||||
.iter()
|
||||
.any(|block| block["type"] == "text" && block["text"] == "Hello there friend"));
|
||||
assert!(content.iter().any(|block| block["type"] == "tool_use"
|
||||
&& block["name"] == "search"
|
||||
&& block["input"]["q"] == "aether"));
|
||||
assert_eq!(product.client_body_json["stop_reason"], "tool_use");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_sync_finalize_projects_authoritative_incomplete_responses_stream() {
|
||||
let report_context = json!({
|
||||
|
||||
@@ -4935,13 +4935,22 @@ pub(crate) fn gemini_response_format_to_canonical(
|
||||
if response_mime_type != "application/json" {
|
||||
return None;
|
||||
}
|
||||
let json_schema = gemini_value_by_case(generation_config, "responseSchema", "response_schema")
|
||||
.map(|schema| {
|
||||
json!({
|
||||
"name": "response_schema",
|
||||
"schema": schema,
|
||||
})
|
||||
});
|
||||
let json_schema = gemini_value_by_case(
|
||||
generation_config,
|
||||
"responseJsonSchema",
|
||||
"response_json_schema",
|
||||
)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
gemini_value_by_case(generation_config, "responseSchema", "response_schema")
|
||||
.map(gemini_openapi_schema_to_json_schema)
|
||||
})
|
||||
.map(|schema| {
|
||||
json!({
|
||||
"name": "response_schema",
|
||||
"schema": schema,
|
||||
})
|
||||
});
|
||||
Some(CanonicalResponseFormat {
|
||||
format_type: if json_schema.is_some() {
|
||||
"json_schema".to_string()
|
||||
@@ -4953,6 +4962,68 @@ pub(crate) fn gemini_response_format_to_canonical(
|
||||
})
|
||||
}
|
||||
|
||||
/// `parametersJsonSchema` is already standard JSON Schema; the legacy
|
||||
/// `parameters` field is Gemini's OpenAPI subset with upper-case type names.
|
||||
fn gemini_declaration_parameters_to_json_schema(declaration: &Map<String, Value>) -> Option<Value> {
|
||||
gemini_value_by_case(
|
||||
declaration,
|
||||
"parametersJsonSchema",
|
||||
"parameters_json_schema",
|
||||
)
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
declaration
|
||||
.get("parameters")
|
||||
.map(gemini_openapi_schema_to_json_schema)
|
||||
})
|
||||
}
|
||||
|
||||
/// Gemini's OpenAPI-style `Schema` spells types in upper case (`OBJECT`,
|
||||
/// `STRING`, ...); other protocols expect JSON Schema's lower-case names.
|
||||
pub(crate) fn gemini_openapi_schema_to_json_schema(schema: &Value) -> Value {
|
||||
fn normalize(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (key, child) in object.iter_mut() {
|
||||
if key == "type" {
|
||||
match child {
|
||||
Value::String(type_name) => lowercase_schema_type(type_name),
|
||||
Value::Array(type_names) => {
|
||||
for type_name in type_names.iter_mut() {
|
||||
if let Value::String(type_name) = type_name {
|
||||
lowercase_schema_type(type_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
other => normalize(other),
|
||||
}
|
||||
} else if key != "enum"
|
||||
&& key != "const"
|
||||
&& key != "default"
|
||||
&& key != "example"
|
||||
{
|
||||
normalize(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => items.iter_mut().for_each(normalize),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn lowercase_schema_type(type_name: &mut String) {
|
||||
if matches!(
|
||||
type_name.as_str(),
|
||||
"OBJECT" | "STRING" | "INTEGER" | "NUMBER" | "BOOLEAN" | "ARRAY" | "NULL"
|
||||
) {
|
||||
*type_name = type_name.to_ascii_lowercase();
|
||||
}
|
||||
}
|
||||
|
||||
let mut schema = schema.clone();
|
||||
normalize(&mut schema);
|
||||
schema
|
||||
}
|
||||
|
||||
pub(crate) type GeminiCanonicalTools = (
|
||||
Vec<CanonicalToolDefinition>,
|
||||
Vec<Value>,
|
||||
@@ -5126,12 +5197,18 @@ pub(crate) fn gemini_tools_to_canonical(value: Option<&Value>) -> Option<GeminiC
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
parameters: declaration_object.get("parameters").cloned(),
|
||||
parameters: gemini_declaration_parameters_to_json_schema(declaration_object),
|
||||
strict: None,
|
||||
extensions: {
|
||||
let mut extensions = gemini_extensions(
|
||||
declaration_object,
|
||||
&["name", "description", "parameters"],
|
||||
&[
|
||||
"name",
|
||||
"description",
|
||||
"parameters",
|
||||
"parametersJsonSchema",
|
||||
"parameters_json_schema",
|
||||
],
|
||||
);
|
||||
if let Some(parameters) = declaration_object.get("parameters").cloned() {
|
||||
canonical_extension_object_mut(&mut extensions, "gemini")
|
||||
@@ -7133,6 +7210,8 @@ const GEMINI_MAPPED_GENERATION_CONFIG_KEYS: &[&str] = &[
|
||||
"response_mime_type",
|
||||
"responseSchema",
|
||||
"response_schema",
|
||||
"responseJsonSchema",
|
||||
"response_json_schema",
|
||||
"responseModalities",
|
||||
"response_modalities",
|
||||
];
|
||||
@@ -9360,6 +9439,80 @@ mod tests {
|
||||
assert_eq!(rebuilt["toolConfig"], request["toolConfig"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_request_adapter_reads_json_schema_fields_and_lowercases_openapi_types() {
|
||||
let request = json!({
|
||||
"contents": [{"role": "user", "parts": [{"text": "hi"}]}],
|
||||
"tools": [{"functionDeclarations": [
|
||||
{
|
||||
"name": "search",
|
||||
"parametersJsonSchema": {
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "string"}},
|
||||
"required": ["q"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "legacy",
|
||||
"parameters": {
|
||||
"type": "OBJECT",
|
||||
"properties": {
|
||||
"type": {"type": "STRING", "enum": ["OBJECT", "STRING"]},
|
||||
"tags": {"type": "ARRAY", "items": {"type": "STRING"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
]}],
|
||||
"generationConfig": {
|
||||
"responseMimeType": "application/json",
|
||||
"responseJsonSchema": {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let canonical =
|
||||
from_gemini_to_canonical_request(&request, "/v1beta/models/grok-4.7:generateContent")
|
||||
.expect("canonical request");
|
||||
|
||||
assert_eq!(
|
||||
canonical.tools[0].parameters,
|
||||
Some(json!({
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "string"}},
|
||||
"required": ["q"]
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
canonical.tools[1].parameters,
|
||||
Some(json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"type": "string", "enum": ["OBJECT", "STRING"]},
|
||||
"tags": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
}))
|
||||
);
|
||||
let response_format = canonical.response_format.as_ref().expect("response format");
|
||||
assert_eq!(response_format.format_type, "json_schema");
|
||||
assert_eq!(
|
||||
response_format.json_schema.as_ref().expect("schema")["schema"]["required"],
|
||||
json!(["name"])
|
||||
);
|
||||
|
||||
let legacy_schema = super::gemini_response_format_to_canonical(Some(&json!({
|
||||
"responseMimeType": "application/json",
|
||||
"responseSchema": {"type": "OBJECT", "properties": {"n": {"type": "INTEGER"}}}
|
||||
})))
|
||||
.expect("legacy response format");
|
||||
assert_eq!(
|
||||
legacy_schema.json_schema.expect("legacy schema")["schema"],
|
||||
json!({"type": "object", "properties": {"n": {"type": "integer"}}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_request_adapter_normalizes_google_search_grounding_aliases() {
|
||||
let cases = [
|
||||
|
||||
Reference in New Issue
Block a user