Merge origin/main into main

Integrate upstream updates while preserving the local analytics dashboards and schema-only migration changes.

Combine user account analysis with upstream user/group usage statistics in separate tabs, retain all migration versions, and keep the deleted audit document removed.

Validation: gateway all-target cargo check, frontend type check and 57 focused tests, 48 migration tests, schema composition checks, and diff whitespace checks.
This commit is contained in:
elky
2026-10-02 11:57:18 +08:00
343 changed files with 27929 additions and 2549 deletions
+6
View File
@@ -208,6 +208,10 @@ pub use crate::formats::{
resolve_stream_spec as resolve_openai_responses_stream_spec,
resolve_sync_spec as resolve_openai_responses_sync_spec, LocalOpenAiResponsesSpec,
},
xai::{
apply_xai_upstream_payload_edits, apply_xai_upstream_payload_edits_with_client,
xai_supports_native_image_generation,
},
},
},
shared::{
@@ -220,9 +224,11 @@ pub use crate::formats::{
standard_normalize::{
build_cross_format_openai_chat_request_body,
build_cross_format_openai_chat_request_body_with_model_directives,
build_cross_format_openai_chat_request_body_with_provider_context,
build_cross_format_openai_responses_request_body,
build_cross_format_openai_responses_request_body_with_model_directives,
build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope,
build_cross_format_openai_responses_request_body_with_provider_context,
build_local_openai_chat_request_body,
build_local_openai_chat_request_body_with_model_directives,
build_local_openai_responses_request_body,
@@ -0,0 +1,108 @@
use std::sync::{OnceLock, RwLock};
/// 当前支持的 Codex 客户端类型。
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CodexClientKind {
Cli,
Desktop,
}
/// Codex 上游请求使用的客户端画像。
///
/// 画像由网关后台任务更新,格式转换层只读取不可变快照,避免在请求路径执行网络操作。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CodexClientProfile {
pub client_kind: CodexClientKind,
pub codex_version: String,
pub originator: String,
pub user_agent: String,
}
impl CodexClientProfile {
/// 从稳定版本号创建 CLI 画像;版本校验由发布检查器负责,构造器只拒绝明显非法值。
pub fn cli(version: &str) -> Result<Self, &'static str> {
let version = version.trim();
if version.is_empty()
|| version.len() > 64
|| !version.bytes().all(|byte| (32..=126).contains(&byte))
{
return Err("invalid Codex CLI version");
}
let originator = "codex_cli_rs".to_owned();
Ok(Self {
client_kind: CodexClientKind::Cli,
codex_version: version.to_owned(),
user_agent: format!("{}/{}", originator, version),
originator,
})
}
}
impl Default for CodexClientProfile {
fn default() -> Self {
// 远程发布检查不可用时仍保持现有线上行为,避免启动或请求被版本服务拖住。
Self::cli("0.153.4").expect("built-in Codex CLI profile must be valid")
}
}
static ACTIVE_PROFILE: OnceLock<RwLock<CodexClientProfile>> = OnceLock::new();
fn active_profile() -> &'static RwLock<CodexClientProfile> {
ACTIVE_PROFILE.get_or_init(|| RwLock::new(CodexClientProfile::default()))
}
/// 返回当前画像的独立快照,调用方不会持有全局锁。
pub fn codex_client_profile() -> CodexClientProfile {
active_profile()
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
/// 原子替换当前画像,并返回替换前的画像。
pub fn set_codex_client_profile(profile: CodexClientProfile) -> CodexClientProfile {
let mut current = active_profile()
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
std::mem::replace(&mut *current, profile)
}
/// 发布一份新的 CLI 画像。
pub fn set_codex_cli_version(version: &str) -> Result<CodexClientProfile, &'static str> {
let profile = CodexClientProfile::cli(version)?;
Ok(set_codex_client_profile(profile))
}
/// 返回当前画像的 Codex Core 版本。
pub fn codex_client_version() -> String {
codex_client_profile().codex_version
}
/// 返回当前画像的 User-Agent。
pub fn codex_client_user_agent() -> String {
codex_client_profile().user_agent
}
/// 返回当前画像的 originator。
pub fn codex_client_originator() -> String {
codex_client_profile().originator
}
#[cfg(test)]
mod tests {
use super::{CodexClientKind, CodexClientProfile};
#[test]
fn cli_profile_derives_wire_identity_from_version() {
let profile = CodexClientProfile::cli("0.200.1").expect("valid version");
assert_eq!(profile.client_kind, CodexClientKind::Cli);
assert_eq!(profile.originator, "codex_cli_rs");
assert_eq!(profile.user_agent, "codex_cli_rs/0.200.1");
}
#[test]
fn cli_profile_rejects_empty_or_control_values() {
assert!(CodexClientProfile::cli("").is_err());
assert!(CodexClientProfile::cli("0.1.0\nspoof").is_err());
}
}
@@ -2,6 +2,7 @@ use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use crate::formats::shared::citations::canonical_citations_to_claude_citations;
use crate::formats::shared::response::{
build_generated_tool_call_id, canonicalize_tool_arguments,
remove_empty_pages_from_tool_arguments,
@@ -773,6 +774,33 @@ impl ClaudeClientEmitter {
name,
content,
} => self.emit_tool_result_block(index, tool_use_id, name, content),
CanonicalStreamEvent::Citations(citations) => {
let citations = canonical_citations_to_claude_citations(&citations);
if citations.is_empty() {
return Ok(Vec::new());
}
// Citations belong to the answer text. If a tool call or a
// thinking block closed it, open a fresh text block rather than
// hang the evidence off an unrelated one.
let mut out = self.ensure_text_block()?;
let Some(ClaudeOpenBlock::Text { block_index }) = self.open_block else {
return Ok(out);
};
for citation in citations {
out.extend(encode_json_sse(
Some("content_block_delta"),
&json!({
"type": "content_block_delta",
"index": block_index,
"delta": {
"type": "citations_delta",
"citation": citation,
}
}),
)?);
}
Ok(out)
}
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
CanonicalStreamEvent::Finish {
finish_reason,
@@ -10,6 +10,8 @@ pub struct FormatContext {
pub upstream_is_stream: bool,
pub report_context: Option<Value>,
pub history_scope: Option<String>,
/// Defer tool schema lowering to the private provider transport boundary.
pub preserve_gemini_tool_schemas: bool,
}
impl FormatContext {
@@ -45,6 +47,7 @@ impl FormatContext {
upstream_is_stream: false,
report_context: self.report_context.clone(),
history_scope: self.history_scope.clone(),
preserve_gemini_tool_schemas: false,
}
}
@@ -9,7 +9,8 @@ use serde_json::{json, Value};
use crate::formats::{
context::FormatContext,
openai::responses::{
openai_responses_message_item_id, openai_responses_synthetic_reasoning_item_id,
openai_responses_message_item_id, openai_responses_reasoning_text_parts,
openai_responses_synthetic_reasoning_item_id,
response::ensure_modern_openai_responses_response_fields,
},
registry,
@@ -205,14 +206,13 @@ pub fn build_openai_responses_response_with_content(
if trimmed.is_empty() {
continue;
}
let content = openai_responses_reasoning_text_parts(std::iter::once(trimmed));
output.push(json!({
"type": "reasoning",
"id": openai_responses_synthetic_reasoning_item_id(response_id, index),
"status": "completed",
"summary": [{
"type": "summary_text",
"text": trimmed,
}]
"summary": [],
"content": content,
}));
}
if !content.is_empty() {
@@ -289,6 +289,31 @@ mod tests {
assert!(converted["completed_at"].as_i64().is_some());
}
#[test]
fn manual_responses_response_builder_puts_reasoning_in_content() {
let response = super::build_openai_responses_response_with_reasoning(
"resp_manual_reason",
"gpt-5",
"answer",
vec!["raw thinking".to_string()],
Vec::new(),
super::OpenAiResponsesResponseUsage {
prompt_tokens: 1,
output_tokens: 2,
total_tokens: 3,
},
);
assert_eq!(response["output"][0]["type"], "reasoning");
assert_eq!(
response["output"][0]["content"][0]["type"],
"reasoning_text"
);
assert_eq!(response["output"][0]["content"][0]["text"], "raw thinking");
assert_eq!(response["output"][0]["summary"], json!([]));
assert_eq!(response["output"][1]["content"][0]["text"], "answer");
}
#[test]
fn manual_responses_response_builder_emits_modern_fields() {
let response = super::build_openai_responses_response(
@@ -306,6 +331,40 @@ mod tests {
assert!(response["completed_at"].as_i64().is_some());
}
#[test]
fn chat_reasoning_content_maps_to_responses_content_and_summary() {
let body = json!({
"id": "chatcmpl-reason",
"object": "chat.completion",
"model": "deepseek-reasoner",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"reasoning_content": "compare the decimals",
"content": "9.80 is larger"
},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
});
let converted = convert_openai_chat_response_to_openai_responses(&body, &json!({}), false)
.expect("responses response");
let item = &converted["output"][0];
assert_eq!(item["type"], "reasoning");
assert_eq!(item["content"][0]["type"], "reasoning_text");
assert_eq!(item["content"][0]["text"], "compare the decimals");
assert_eq!(item["summary"], json!([]));
assert!(!item.get("content").unwrap().is_null());
assert_eq!(converted["output"][1]["type"], "message");
assert_eq!(
converted["output"][1]["content"][0]["text"],
"9.80 is larger"
);
}
#[test]
fn pairwise_response_helper_uses_report_context_model_fallback() {
let body = json!({
@@ -31,10 +31,11 @@ pub fn from(body: &Value, ctx: &FormatContext) -> Option<CanonicalRequest> {
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
to_raw(
to_raw_with_schema_policy(
request,
ctx.mapped_model_or(request.model.as_str()),
ctx.upstream_is_stream,
ctx.preserve_gemini_tool_schemas,
)
}
@@ -187,7 +188,21 @@ pub fn to_raw(
mapped_model: &str,
upstream_is_stream: bool,
) -> Option<Value> {
let mut output = canonical_to_gemini_request_body(canonical, mapped_model, upstream_is_stream)?;
to_raw_with_schema_policy(canonical, mapped_model, upstream_is_stream, false)
}
fn to_raw_with_schema_policy(
canonical: &CanonicalRequest,
mapped_model: &str,
upstream_is_stream: bool,
preserve_tool_schemas: bool,
) -> Option<Value> {
let mut output = canonical_to_gemini_request_body(
canonical,
mapped_model,
upstream_is_stream,
preserve_tool_schemas,
)?;
apply_gemini_request_extensions(&mut output, &canonical.extensions)?;
if !canonical_has_raw_gemini_tools(canonical) {
enable_server_side_tool_invocations_for_mixed_tools(&mut output, mapped_model)?;
@@ -244,7 +259,8 @@ pub fn ensure_server_side_tool_invocations_for_mixed_tools(output: &mut Value) -
}
pub(crate) fn canonical_has_mixed_gemini_tools(canonical: &CanonicalRequest) -> bool {
canonical_tools_to_gemini(canonical)
// Only tool kinds matter here; do not lower/expand schemas just to count them.
canonical_tools_to_gemini(canonical, true)
.and_then(|tools| tools.as_array().cloned())
.is_some_and(|tools| gemini_tools_are_mixed(&tools))
}
@@ -275,6 +291,7 @@ fn canonical_to_gemini_request_body(
canonical: &CanonicalRequest,
mapped_model: &str,
_upstream_is_stream: bool,
preserve_tool_schemas: bool,
) -> Option<Value> {
let mut output = Map::new();
if !mapped_model.trim().is_empty() {
@@ -297,7 +314,7 @@ fn canonical_to_gemini_request_body(
{
output.insert("generationConfig".to_string(), generation_config);
}
if let Some(tools) = canonical_tools_to_gemini(canonical) {
if let Some(tools) = canonical_tools_to_gemini(canonical, preserve_tool_schemas) {
output.insert("tools".to_string(), tools);
}
if let Some(tool_config) = canonical_tool_choice_to_gemini(canonical.tool_choice.as_ref()) {
@@ -701,7 +718,10 @@ fn apply_response_format_to_gemini_generation_config(
}
}
fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
fn canonical_tools_to_gemini(
canonical: &CanonicalRequest,
preserve_tool_schemas: bool,
) -> Option<Value> {
let mut declarations = Vec::new();
let mut tools = Vec::new();
let mut google_search = canonical
@@ -717,7 +737,7 @@ fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
let mut url_context = false;
for tool in &canonical.tools {
match normalize_gemini_builtin_tool_name(&tool.name) {
match canonical_tool_builtin_gemini_name(tool) {
Some("googleSearch") => {
google_search = true;
continue;
@@ -747,7 +767,10 @@ fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
google_search = true;
continue;
}
declarations.push(canonical_tool_to_gemini_declaration(tool));
declarations.push(canonical_tool_to_gemini_declaration(
tool,
preserve_tool_schemas,
));
}
let mut emitted_google_search = false;
let mut emitted_code_execution = false;
@@ -877,7 +900,10 @@ fn gemini_unhandled_builtin_tool_portion(tool_object: &Map<String, Value>) -> Op
(!builtin.is_empty()).then_some(Value::Object(builtin))
}
fn canonical_tool_to_gemini_declaration(tool: &CanonicalToolDefinition) -> Value {
fn canonical_tool_to_gemini_declaration(
tool: &CanonicalToolDefinition,
preserve_tool_schema: bool,
) -> Value {
let mut declaration = Map::new();
declaration.insert("name".to_string(), Value::String(tool.name.clone()));
if let Some(description) = &tool.description {
@@ -898,7 +924,7 @@ fn canonical_tool_to_gemini_declaration(tool: &CanonicalToolDefinition) -> Value
.clone()
.or_else(|| tool.parameters.clone())
.map(|mut schema| {
if raw_parameters.is_none() {
if raw_parameters.is_none() && !preserve_tool_schema {
clean_gemini_schema(&mut schema);
}
schema
@@ -980,6 +1006,25 @@ fn compact_gemini_contents(contents: Vec<Value>) -> Vec<Value> {
compact
}
/// Promote a canonical tool to a Gemini builtin only when it is a bare marker.
///
/// Clients declare ordinary function tools whose names collide with the builtin
/// spellings — Claude Code ships a client-side `WebSearch` tool with a full
/// `input_schema`. Matching on the name alone dropped those declarations and
/// replaced them with server-side grounding, so the model could never call the
/// tool the client actually implements. A declared schema means the caller
/// expects to execute the call itself, so such tools stay function declarations.
fn canonical_tool_builtin_gemini_name(tool: &CanonicalToolDefinition) -> Option<&'static str> {
if tool
.parameters
.as_ref()
.is_some_and(|parameters| !parameters.is_null())
{
return None;
}
normalize_gemini_builtin_tool_name(&tool.name)
}
fn normalize_gemini_builtin_tool_name(name: &str) -> Option<&'static str> {
match name
.trim()
@@ -1198,43 +1243,46 @@ mod tests {
#[test]
fn canonical_tool_declaration_sanitizes_json_schema_for_gemini() {
let declaration = canonical_tool_to_gemini_declaration(&CanonicalToolDefinition {
name: "inspect".to_string(),
description: None,
parameters: Some(json!({
"$defs": {
"Target": {
"type": "object",
"properties": {
"secret": {
"type": "string",
"encrypted": true
}
let declaration = canonical_tool_to_gemini_declaration(
&CanonicalToolDefinition {
name: "inspect".to_string(),
description: None,
parameters: Some(json!({
"$defs": {
"Target": {
"type": "object",
"properties": {
"secret": {
"type": "string",
"encrypted": true
}
},
"required": ["secret"],
"additionalProperties": false
}
},
"type": "object",
"properties": {
"target": {
"oneOf": [
{"$ref": "#/$defs/Target"},
{"type": "null"}
]
},
"required": ["secret"],
"additionalProperties": false
"mode": {
"type": ["string", "null"],
"enum": [1, "fast"]
},
"value": {
"type": ["string", "integer"]
}
}
},
"type": "object",
"properties": {
"target": {
"oneOf": [
{"$ref": "#/$defs/Target"},
{"type": "null"}
]
},
"mode": {
"type": ["string", "null"],
"enum": [1, "fast"]
},
"value": {
"type": ["string", "integer"]
}
}
})),
strict: None,
extensions: BTreeMap::new(),
});
})),
strict: None,
extensions: BTreeMap::new(),
},
false,
);
assert_eq!(
declaration["parameters"],
@@ -1323,4 +1371,62 @@ mod tests {
assert!(to_raw(&canonical, "gemini-2.5-pro", false).is_none());
assert!(to_raw(&canonical, "gemini-3-flash-preview", false).is_some());
}
#[test]
fn client_declared_web_search_tool_stays_a_function_declaration() {
let canonical = CanonicalRequest {
model: "gemini-3-flash-preview".to_string(),
tools: vec![CanonicalToolDefinition {
name: "WebSearch".to_string(),
description: Some("Search the web".to_string()),
parameters: Some(json!({
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
})),
strict: None,
extensions: BTreeMap::new(),
}],
..CanonicalRequest::default()
};
for preserve_tool_schemas in [false, true] {
let tools = canonical_tools_to_gemini(&canonical, preserve_tool_schemas)
.expect("tools should be emitted");
let tools = tools.as_array().expect("tools should be an array");
assert!(
tools.iter().all(|tool| tool.get("googleSearch").is_none()),
"a client tool named WebSearch must not become server-side grounding: {tools:?}"
);
assert_eq!(
tools[0]["functionDeclarations"][0]["name"], "WebSearch",
"the client declaration must survive: {tools:?}"
);
}
}
#[test]
fn schemaless_builtin_tool_name_still_maps_to_google_search() {
let canonical = CanonicalRequest {
model: "gemini-3-flash-preview".to_string(),
tools: vec![CanonicalToolDefinition {
name: "google_search".to_string(),
description: None,
parameters: None,
strict: None,
extensions: BTreeMap::new(),
}],
..CanonicalRequest::default()
};
for preserve_tool_schemas in [false, true] {
let tools = canonical_tools_to_gemini(&canonical, preserve_tool_schemas)
.expect("tools should be emitted");
let tools = tools.as_array().expect("tools should be an array");
assert_eq!(tools.len(), 1, "{tools:?}");
assert_eq!(tools[0]["googleSearch"], json!({}));
}
}
}
@@ -2,15 +2,176 @@ use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
formats::shared::citations::{
canonical_citation, canonical_citations_to_claude_citations,
canonical_citations_to_openai_annotations,
},
protocol::canonical::{
canonical_extension_object_mut, canonical_usage_total_input_tokens,
canonical_usage_total_tokens_for_inclusive_input, gemini_extensions,
gemini_part_to_canonical_block, gemini_stop_reason_to_canonical, gemini_usage_to_canonical,
CanonicalContentBlock, CanonicalResponse, CanonicalResponseOutput, CanonicalRole,
CanonicalStopReason, CanonicalUsage,
CanonicalStopReason, CanonicalUsage, CLAUDE_EXTENSION_NAMESPACE,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
},
};
/// Project Gemini grounding metadata onto the answer text as structured
/// citations.
///
/// Native `googleSearch` grounding runs inside Google, so there is no
/// client-visible tool call and the evidence only exists in
/// `candidates[].groundingMetadata`. Cross-format targets used to drop that
/// wholesale, leaving callers with prose that names its sources but nothing a
/// client can render or verify. Every grounded span is therefore emitted twice,
/// each time in the target family's own standard shape: OpenAI `url_citation`
/// annotations and Claude `web_search_result_location` citations. Both ride
/// extension namespaces the respective emitters already merge onto the text
/// block, so no target has to learn anything Gemini-specific.
fn attach_gemini_grounding_citations(
candidate: &Map<String, Value>,
content: &mut [CanonicalContentBlock],
) {
let Some(grounding) = gemini_candidate_grounding(candidate) else {
return;
};
let Some(block) = content.iter_mut().find(|block| {
matches!(block, CanonicalContentBlock::Text { text, .. } if !text.trim().is_empty())
}) else {
return;
};
let CanonicalContentBlock::Text { text, extensions } = block else {
return;
};
let citations = gemini_grounding_citations(grounding, text);
if citations.is_empty() {
return;
}
let annotations = canonical_citations_to_openai_annotations(&citations);
let claude_citations = canonical_citations_to_claude_citations(&citations);
canonical_extension_object_mut(extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.entry("annotations".to_string())
.or_insert_with(|| Value::Array(annotations));
canonical_extension_object_mut(extensions, CLAUDE_EXTENSION_NAMESPACE)
.entry("citations".to_string())
.or_insert_with(|| Value::Array(claude_citations));
}
pub(crate) fn gemini_candidate_grounding(candidate: &Map<String, Value>) -> Option<&Value> {
candidate
.get("groundingMetadata")
.or_else(|| candidate.get("grounding_metadata"))
}
/// Normalise `groundingMetadata` into neutral citations against `text`.
///
/// Gemini reports segment bounds as UTF-8 byte offsets while every target
/// counts characters, so the bounds are converted rather than copied.
pub(crate) fn gemini_grounding_citations(grounding: &Value, text: &str) -> Vec<Value> {
let chunks = grounding
.get("groundingChunks")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
if chunks.is_empty() {
return Vec::new();
}
let supports = grounding
.get("groundingSupports")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
let mut citations = Vec::new();
for support in supports {
let segment = support.get("segment");
let start = segment
.and_then(|segment| segment.get("startIndex"))
.and_then(Value::as_u64)
.unwrap_or(0);
let end = segment
.and_then(|segment| segment.get("endIndex"))
.and_then(Value::as_u64);
let start_byte = gemini_clamped_byte_offset(text, start);
let end_byte = end
.map(|end| gemini_clamped_byte_offset(text, end))
.filter(|end| *end >= start_byte);
let cited_text = segment
.and_then(|segment| segment.get("text"))
.and_then(Value::as_str)
.or_else(|| end_byte.map(|end| &text[start_byte..end]))
.map(str::trim)
.filter(|cited_text| !cited_text.is_empty());
let indices = support
.get("groundingChunkIndices")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
for index in indices {
let Some(chunk) = index
.as_u64()
.and_then(|index| usize::try_from(index).ok())
.and_then(|index| chunks.get(index))
else {
continue;
};
let Some((uri, title)) = gemini_grounding_chunk_source(chunk) else {
continue;
};
citations.push(canonical_citation(
uri,
title,
Some(text[..start_byte].chars().count()),
end_byte.map(|end| text[..end].chars().count()),
cited_text,
));
}
}
// `groundingSupports` is optional; without it the chunks are still the
// evidence, just unanchored.
if citations.is_empty() {
for chunk in chunks {
let Some((uri, title)) = gemini_grounding_chunk_source(chunk) else {
continue;
};
citations.push(canonical_citation(uri, title, None, None, None));
}
}
citations
}
fn gemini_grounding_chunk_source(chunk: &Value) -> Option<(&str, Option<&str>)> {
let source = chunk.get("web").or_else(|| chunk.get("retrievedContext"))?;
let uri = source
.get("uri")
.or_else(|| source.get("url"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|uri| !uri.is_empty())?;
let title = source
.get("title")
.and_then(Value::as_str)
.map(str::trim)
.filter(|title| !title.is_empty());
Some((uri, title))
}
/// Gemini offsets are byte counts into the UTF-8 answer. A truncated or stale
/// offset must not panic the conversion, so snap it into range and back onto a
/// character boundary.
fn gemini_clamped_byte_offset(text: &str, byte_offset: u64) -> usize {
let mut offset = usize::try_from(byte_offset)
.unwrap_or(text.len())
.min(text.len());
while offset > 0 && !text.is_char_boundary(offset) {
offset -= 1;
}
offset
}
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
from_raw(body)
}
@@ -37,11 +198,12 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
let content = parts
let mut content = parts
.iter()
.enumerate()
.filter_map(|(index, part)| gemini_part_to_canonical_block(part, index))
.collect::<Vec<_>>();
attach_gemini_grounding_citations(candidate_object, &mut content);
let mut stop_reason = candidate_object
.get("finishReason")
.or_else(|| candidate_object.get("finish_reason"))
@@ -433,6 +595,49 @@ mod tests {
use super::*;
use crate::CanonicalContentBlock;
/// Gemini omits `groundingSupports` when it cannot anchor the answer to a
/// span. The sources are still real, so they must survive unanchored
/// rather than be dropped for lacking offsets.
#[test]
fn grounding_without_supports_still_yields_unanchored_citations() {
let body = json!({
"responseId": "resp-unanchored",
"candidates": [{
"content": {"role": "model", "parts": [{"text": "Rust 1.95 is current."}]},
"finishReason": "STOP",
"groundingMetadata": {
"groundingChunks": [
{"web": {"uri": "https://blog.rust-lang.org/", "title": "Rust Blog"}},
{"web": {"title": "no uri here"}}
]
}
}]
});
let canonical = from_raw(&body).expect("canonical");
let CanonicalContentBlock::Text { extensions, .. } = &canonical.outputs[0].content[0]
else {
panic!("expected a text block");
};
assert_eq!(
extensions["claude"]["citations"],
json!([{
"type": "web_search_result_location",
"url": "https://blog.rust-lang.org/",
"title": "Rust Blog",
}])
);
assert_eq!(
extensions["openai_responses"]["annotations"],
json!([{
"type": "url_citation",
"url": "https://blog.rust-lang.org/",
"title": "Rust Blog",
}])
);
}
#[test]
fn gemini_response_without_visible_parts_is_not_success() {
let body = json!({
@@ -2,6 +2,9 @@ use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use crate::formats::gemini::generate_content::response::{
gemini_candidate_grounding, gemini_grounding_citations,
};
use crate::formats::shared::response::{build_generated_tool_call_id, canonicalize_tool_arguments};
use crate::formats::shared::sse::encode_json_sse;
use crate::formats::shared::stream_core::common::*;
@@ -36,6 +39,10 @@ pub struct GeminiProviderState {
content_parts: BTreeMap<usize, CanonicalContentPart>,
tool_calls: BTreeMap<usize, GeminiProviderToolState>,
tool_results: BTreeMap<usize, GeminiProviderToolResultState>,
/// Last `groundingMetadata` seen. Gemini resends it cumulatively, so the
/// newest copy is the complete one; citations are emitted once at finish,
/// when the answer text they index into is whole.
grounding: Option<Value>,
}
impl GeminiProviderState {
@@ -68,6 +75,31 @@ impl GeminiProviderState {
self.started = true;
}
/// Turn the grounding metadata collected over the stream into citations.
///
/// The offsets Gemini reports index into the finished answer, so this can
/// only run once the text is complete — hence a single frame just ahead of
/// `Finish` rather than a delta per chunk.
fn push_citations_frame(&mut self, id: &str, model: &str, out: &mut Vec<CanonicalStreamFrame>) {
let Some(grounding) = self.grounding.take() else {
return;
};
let text = self
.text_parts
.values()
.map(String::as_str)
.collect::<String>();
let citations = gemini_grounding_citations(&grounding, &text);
if citations.is_empty() {
return;
}
out.push(CanonicalStreamFrame {
id: id.to_string(),
model: model.to_string(),
event: CanonicalStreamEvent::Citations(citations),
});
}
fn unknown_frame(&self, report_context: &Value, payload: Value) -> CanonicalStreamFrame {
let (id, model) = self.identity(report_context);
CanonicalStreamFrame {
@@ -120,6 +152,11 @@ impl GeminiProviderState {
response_model.as_str(),
event_object.get("usageMetadata"),
);
if !self.terminal_observation_only {
if let Some(grounding) = gemini_candidate_grounding(candidate_object) {
self.grounding = Some(grounding.clone());
}
}
let Some(content) = candidate_object.get("content").and_then(Value::as_object) else {
if let Some(payload) = terminal_error {
out.push(self.unknown_frame(report_context, payload));
@@ -286,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")
@@ -361,6 +404,7 @@ impl GeminiProviderState {
if has_tool_calls && finish_reason.as_deref().is_none_or(|value| value == "stop") {
finish_reason = Some("tool_calls".to_string());
}
self.push_citations_frame(&id, &model, &mut out);
out.push(CanonicalStreamFrame {
id,
model,
@@ -385,14 +429,17 @@ impl GeminiProviderState {
}
self.finished = true;
let (id, model) = self.identity(report_context);
Ok(vec![CanonicalStreamFrame {
let mut out = Vec::new();
self.push_citations_frame(&id, &model, &mut out);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::Finish {
finish_reason: None,
usage: None,
},
}])
});
Ok(out)
}
}
@@ -674,6 +721,9 @@ impl GeminiClientEmitter {
None,
None,
),
// Only Gemini produces citations today, and a Gemini-to-Gemini
// stream keeps its own `groundingMetadata` on the passthrough path.
CanonicalStreamEvent::Citations(_) => Ok(Vec::new()),
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
CanonicalStreamEvent::Finish {
finish_reason,
@@ -1482,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();
@@ -1,6 +1,6 @@
use std::collections::BTreeMap;
use serde_json::{json, Value};
use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
@@ -13,6 +13,53 @@ use crate::{
},
};
/// Reasoning text carried by one Chat Completions `message` or streaming
/// `delta`, paired with the provider's reasoning block index where one exists.
///
/// The field name is not standardized. DeepSeek-style upstreams send
/// `reasoning_content`; OpenRouter sends `reasoning` alongside a structured
/// `reasoning_details` array. OpenRouter repeats the same text in both of its
/// fields, so exactly one source is read per object and `reasoning_details`
/// wins because only it carries the block index.
pub(crate) fn openai_chat_reasoning_texts(
object: &Map<String, Value>,
) -> Vec<(Option<usize>, String)> {
if let Some(details) = object.get("reasoning_details").and_then(Value::as_array) {
let texts = details
.iter()
.filter_map(Value::as_object)
.filter_map(|detail| {
// `reasoning.encrypted` carries opaque provider state rather
// than readable text, so it has nothing to hand downstream.
if detail.get("type").and_then(Value::as_str) == Some("reasoning.encrypted") {
return None;
}
let text = detail
.get("text")
.or_else(|| detail.get("summary"))
.and_then(Value::as_str)
.filter(|text| !text.is_empty())?;
let index = detail
.get("index")
.and_then(Value::as_u64)
.map(|index| index as usize);
Some((index, text.to_string()))
})
.collect::<Vec<_>>();
if !texts.is_empty() {
return texts;
}
}
// A provider may null out one spelling while filling the other, so skip
// past any key that is present but carries no string.
["reasoning_content", "reasoning"]
.iter()
.find_map(|key| object.get(*key).and_then(Value::as_str))
.filter(|text| !text.is_empty())
.map(|text| vec![(None, text.to_string())])
.unwrap_or_default()
}
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
from_raw(body)
}
@@ -40,21 +87,18 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
.iter()
.any(|block| matches!(block, CanonicalContentBlock::Thinking { .. }))
{
if let Some(reasoning_content) = message
.get("reasoning_content")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
{
content.insert(
0,
CanonicalContentBlock::Thinking {
text: reasoning_content.to_string(),
signature: None,
encrypted_content: None,
extensions: BTreeMap::new(),
},
);
}
let thinking = openai_chat_reasoning_texts(message)
.into_iter()
.map(|(_, text)| text)
.filter(|text| !text.trim().is_empty())
.map(|text| CanonicalContentBlock::Thinking {
text,
signature: None,
encrypted_content: None,
extensions: BTreeMap::new(),
})
.collect::<Vec<_>>();
content.splice(0..0, thinking);
}
let stop_reason =
openai_finish_reason_to_canonical(choice.get("finish_reason").and_then(Value::as_str));
@@ -179,3 +223,161 @@ pub fn to_raw(canonical: &CanonicalResponse) -> Value {
}
response
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::canonical::CanonicalContentBlock;
fn thinking_texts(response: &CanonicalResponse) -> Vec<String> {
response
.content
.iter()
.filter_map(|block| match block {
CanonicalContentBlock::Thinking { text, .. } => Some(text.clone()),
_ => None,
})
.collect()
}
#[test]
fn openrouter_reasoning_details_become_thinking_blocks() {
let response = from_raw(&json!({
"id": "gen-openrouter-123",
"model": "stealth/ox-alpha",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "done",
"reasoning": "step onestep two",
"reasoning_details": [
{"type": "reasoning.text", "text": "step one", "index": 0},
{"type": "reasoning.text", "text": "step two", "index": 1}
]
},
"finish_reason": "stop"
}]
}))
.expect("openrouter response should convert");
// `reasoning` repeats the same text the details already carry, so the
// details win and the provider's own segmentation survives.
assert_eq!(thinking_texts(&response), vec!["step one", "step two"]);
}
#[test]
fn openrouter_reasoning_string_becomes_a_thinking_block() {
let response = from_raw(&json!({
"id": "gen-openrouter-123",
"model": "stealth/ox-alpha",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "done",
"reasoning": "thought about it"
},
"finish_reason": "stop"
}]
}))
.expect("openrouter response should convert");
assert_eq!(thinking_texts(&response), vec!["thought about it"]);
}
#[test]
fn deepseek_reasoning_content_still_becomes_a_thinking_block() {
let response = from_raw(&json!({
"id": "chatcmpl-deepseek",
"model": "deepseek-reasoner",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "42",
"reasoning_content": "let me work it out"
},
"finish_reason": "stop"
}]
}))
.expect("deepseek response should convert");
assert_eq!(thinking_texts(&response), vec!["let me work it out"]);
}
#[test]
fn deepseek_reasoning_content_wins_over_a_bare_reasoning_field() {
let response = from_raw(&json!({
"id": "chatcmpl-deepseek",
"model": "deepseek-reasoner",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "42",
"reasoning_content": "the real one",
"reasoning": "the other spelling"
},
"finish_reason": "stop"
}]
}))
.expect("deepseek response should convert");
assert_eq!(thinking_texts(&response), vec!["the real one"]);
}
#[test]
fn blank_reasoning_content_produces_no_thinking_block() {
let response = from_raw(&json!({
"id": "chatcmpl-deepseek",
"model": "deepseek-reasoner",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "42", "reasoning_content": " "},
"finish_reason": "stop"
}]
}))
.expect("deepseek response should convert");
assert!(thinking_texts(&response).is_empty());
}
#[test]
fn plain_openai_response_without_reasoning_is_unchanged() {
let response = from_raw(&json!({
"id": "chatcmpl-openai",
"model": "gpt-4o",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "hello"},
"finish_reason": "stop"
}]
}))
.expect("openai response should convert");
assert!(thinking_texts(&response).is_empty());
}
#[test]
fn encrypted_reasoning_details_carry_no_thinking_text() {
let response = from_raw(&json!({
"id": "gen-openrouter-123",
"model": "stealth/ox-alpha",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "done",
"reasoning_details": [
{"type": "reasoning.encrypted", "data": "b3BhcXVl", "index": 0}
]
},
"finish_reason": "stop"
}]
}))
.expect("openrouter response should convert");
assert!(thinking_texts(&response).is_empty());
}
}
File diff suppressed because it is too large Load Diff
@@ -147,6 +147,9 @@ fn finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_
finalization.provider_api_format,
reasoning_replay_policy,
);
if crate::is_openai_responses_family_format(finalization.provider_api_format) {
super::responses::normalize_openai_responses_call_ids(body);
}
if finalization
.provider_api_format
.trim()
@@ -226,7 +229,7 @@ fn validate_final_openai_provider_request_contract(
#[cfg(test)]
mod tests {
use serde_json::json;
use serde_json::{json, Value};
use super::{
finalize_openai_provider_request,
@@ -235,6 +238,79 @@ mod tests {
};
use crate::CodexResponsesModelCapabilities;
#[test]
fn finalization_bounds_responses_call_ids_and_preserves_pairing() {
let long_id = format!("call_{}", "a".repeat(78));
let original = json!({
"model": "gpt-5.4",
"input": [
{"type": "function_call", "call_id": long_id, "name": "lookup", "arguments": "{}"},
{"type": "function_call_output", "call_id": long_id, "output": "result"}
]
});
for (source_api_format, provider_type, provider_api_format, websocket_continuation) in [
("openai:responses", "codex", "openai:responses", false),
(
"openai:responses",
"codex",
"openai:responses:compact",
false,
),
("openai:responses", "openai", "openai:responses", false),
(
"openai:responses",
"openai",
"openai:responses:compact",
false,
),
("openai:responses", "codex", "openai:responses", true),
("openai:chat", "codex", "openai:responses", false),
("openai:chat", "openai", "openai:responses", false),
("claude:messages", "codex", "openai:responses", false),
("claude:messages", "openai", "openai:responses", false),
(
"gemini:generate_content",
"codex",
"openai:responses",
false,
),
(
"gemini:generate_content",
"openai",
"openai:responses",
false,
),
] {
let mut body = original.clone();
let finalization = OpenAiProviderRequestFinalization {
source_api_format,
provider_api_format,
provider_type,
provider_model: "gpt-5.4",
source_model: "gpt-5.4",
body_rules: None,
upstream_is_stream: false,
require_body_stream_field: true,
};
if websocket_continuation {
super::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy_for_websocket_continuation(
&mut body,
finalization,
None,
crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds,
)
} else {
finalize_openai_provider_request(&mut body, finalization)
}
.expect("request should finalize");
let call_id = body["input"][0]["call_id"].as_str().expect("call ID");
assert!(call_id.len() <= 64, "call ID has {} bytes", call_id.len());
assert_eq!(body["input"][1]["call_id"], call_id);
}
}
#[test]
fn validates_reasoning_and_prompt_cache_against_the_final_provider_model() {
let body = json!({
@@ -511,6 +587,61 @@ mod tests {
}
}
#[test]
fn codex_finalization_preserves_explicit_service_tiers() {
let capabilities = CodexResponsesModelCapabilities {
use_responses_lite: false,
supports_reasoning_summary_parameter: false,
default_reasoning_effort: None,
default_reasoning_summary: None,
supported_reasoning_efforts: Vec::new(),
supports_parallel_tool_calls: true,
support_verbosity: false,
default_verbosity: None,
supported_service_tiers: vec!["priority".to_string()],
};
for source_api_format in ["openai:responses", "openai:chat"] {
for provider_api_format in ["openai:responses", "openai:responses:compact"] {
for model_capabilities in [None, Some(&capabilities)] {
for service_tier in [
Some("ultrafast"),
Some("priority"),
Some("default"),
Some("auto"),
Some("flex"),
Some("future-tier"),
None,
] {
let mut body = json!({"model": "gpt-5.6-sol", "input": []});
if let Some(service_tier) = service_tier {
body["service_tier"] = json!(service_tier);
}
finalize_openai_provider_request_with_codex_model_capabilities(
&mut body,
OpenAiProviderRequestFinalization {
source_api_format,
provider_api_format,
provider_type: "codex",
provider_model: "gpt-5.6-sol",
source_model: "gpt-5.6-sol",
body_rules: None,
upstream_is_stream: true,
require_body_stream_field: true,
},
model_capabilities,
)
.expect("explicit service tiers should be validated by the upstream");
assert_eq!(
body.get("service_tier").and_then(Value::as_str),
service_tier,
"{source_api_format} -> {provider_api_format}",
);
}
}
}
}
}
#[test]
fn dynamic_codex_card_preserves_default_effort_and_keeps_mode_model_specific() {
let finalization = OpenAiProviderRequestFinalization {
@@ -1,6 +1,7 @@
use std::collections::BTreeMap;
use std::sync::OnceLock;
use crate::codex_profile::codex_client_profile;
use aether_ai_formats::provider_compat::proxy::rules::body_rules_handle_path;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@@ -36,9 +37,6 @@ const CODEX_OPENAI_RESPONSES_COMPACT_BODY_FIELDS: &[&str] = &[
"prompt_cache_key",
"text",
];
pub const CODEX_CLIENT_VERSION: &str = "0.153.4";
pub const CODEX_CLIENT_USER_AGENT: &str = "codex_cli_rs/0.153.4";
pub const CODEX_CLIENT_ORIGINATOR: &str = "codex_cli_rs";
pub const CODEX_OPENAI_IMAGE_INTERNAL_MODEL: &str = "gpt-5.4-mini";
pub const CODEX_OPENAI_IMAGE_DEFAULT_MODEL: &str = "gpt-image-2";
pub const CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL: &str = "dall-e-2";
@@ -91,12 +89,6 @@ impl CodexResponsesModelCapabilities {
.iter()
.any(|candidate| candidate == effort.trim())
}
fn supports_service_tier(&self, service_tier: &str) -> bool {
self.supported_service_tiers
.iter()
.any(|candidate| candidate == service_tier)
}
}
fn codex_namespaced_model_suffix(model: &str) -> Option<&str> {
@@ -1214,18 +1206,6 @@ fn apply_codex_model_request_capabilities(
}
}
}
if !body_rules_handle_path(body_rules, "service_tier") {
let service_tier = body_object
.get("service_tier")
.and_then(Value::as_str)
.map(str::to_string);
if !service_tier.as_deref().is_some_and(|service_tier| {
service_tier != "default" && capabilities.supports_service_tier(service_tier)
}) {
body_object.remove("service_tier");
}
}
}
fn ensure_codex_reasoning_defaults(
@@ -2116,6 +2096,7 @@ pub fn apply_codex_openai_special_headers(
};
let auth_identity = parse_codex_auth_identity(decrypted_auth_config_raw);
let client_profile = codex_client_profile();
remove_btree_header(provider_request_headers, "chatgpt-account-id");
remove_btree_header(provider_request_headers, "x-openai-fedramp");
@@ -2130,12 +2111,12 @@ pub fn apply_codex_openai_special_headers(
set_codex_client_header(
provider_request_headers,
"user-agent",
CODEX_CLIENT_USER_AGENT,
&client_profile.user_agent,
);
set_codex_client_header(
provider_request_headers,
"originator",
CODEX_CLIENT_ORIGINATOR,
&client_profile.originator,
);
if endpoint_kind == CodexOpenAiEndpointKind::Search {
remove_btree_header(provider_request_headers, CODEX_RESPONSES_LITE_HEADER);
@@ -2193,17 +2174,18 @@ mod tests {
build_codex_model_catalog_metadata, bundled_codex_model_cards, effective_codex_model_cards,
parse_codex_auth_identity, project_codex_catalog_model_card,
resolve_codex_responses_model_capabilities,
validate_codex_openai_responses_compact_request_contract, CODEX_CLIENT_ORIGINATOR,
CODEX_CLIENT_USER_AGENT, CODEX_CLIENT_VERSION, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS, CODEX_RESPONSES_LITE_HEADER,
validate_codex_openai_responses_compact_request_contract,
CODEX_OPENAI_IMAGE_INTERNAL_MODEL, CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS,
CODEX_RESPONSES_LITE_HEADER,
};
use serde_json::{json, Value};
#[test]
fn codex_client_user_agent_matches_originator_and_version() {
let profile = crate::codex_client_profile();
assert_eq!(
CODEX_CLIENT_USER_AGENT,
format!("{CODEX_CLIENT_ORIGINATOR}/{CODEX_CLIENT_VERSION}")
profile.user_agent,
format!("{}/{}", profile.originator, profile.codex_version)
);
}
@@ -2654,7 +2636,7 @@ mod tests {
assert_eq!(body["reasoning"]["effort"], "high");
assert_eq!(body["include"], json!(["reasoning.encrypted_content"]));
assert_eq!(body["parallel_tool_calls"], false);
assert!(body.get("service_tier").is_none());
assert_eq!(body["service_tier"], "priority");
assert!(body["text"].get("verbosity").is_none());
assert_eq!(body["text"]["format"]["type"], "json_schema");
@@ -2981,11 +2963,11 @@ mod tests {
);
assert_eq!(
headers.get("user-agent").map(String::as_str),
Some(CODEX_CLIENT_USER_AGENT)
Some(crate::codex_client_user_agent().as_str())
);
assert_eq!(
headers.get("originator").map(String::as_str),
Some(CODEX_CLIENT_ORIGINATOR)
Some(crate::codex_client_originator().as_str())
);
assert!(!headers.contains_key(CODEX_RESPONSES_LITE_HEADER));
assert!(!headers.contains_key("openai-beta"));
@@ -3019,11 +3001,11 @@ mod tests {
);
assert_eq!(
headers.get("user-agent").map(String::as_str),
Some(CODEX_CLIENT_USER_AGENT)
Some(crate::codex_client_user_agent().as_str())
);
assert_eq!(
headers.get("originator").map(String::as_str),
Some(CODEX_CLIENT_ORIGINATOR)
Some(crate::codex_client_originator().as_str())
);
assert!(!headers.contains_key(CODEX_RESPONSES_LITE_HEADER));
}
@@ -1,5 +1,9 @@
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
use serde_json::Value;
use base64::{
engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD},
Engine as _,
};
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
pub mod codex;
pub(crate) mod history;
@@ -7,6 +11,7 @@ pub mod request;
pub mod response;
pub mod spec;
pub mod stream;
pub mod xai;
const TOOL_ERROR_PREFIX: &str = "[tool error]";
const AETHER_REASONING_ITEM_ID_PREFIX: &str = "rs_aether_";
@@ -85,6 +90,8 @@ pub enum OpenAiResponsesReasoningReplayPolicy {
#[default]
OpenAiItemIds,
DeepSeekOpaque,
/// xAI replays encrypted state without requiring OpenAI's item-ID prefix.
XaiEncrypted,
}
/// Builds a stable, wire-compatible ID for a reasoning item synthesized by Aether.
@@ -119,6 +126,50 @@ pub fn openai_responses_message_item_id(response_id: &str, output_index: usize)
)
}
/// Builds the Responses reasoning `content` array from raw thinking text.
///
/// Raw chain-of-thought belongs in `content` as `reasoning_text` parts. It is
/// deliberately *not* mirrored into `summary`: OpenAI keeps the two channels
/// distinct, and clients such as Codex render both, so duplicating the same
/// text onto `summary` made the thinking panel print everything twice.
pub(crate) fn openai_responses_reasoning_text_parts(
texts: impl IntoIterator<Item = impl AsRef<str>>,
) -> Value {
Value::Array(
texts
.into_iter()
.map(|text| text.as_ref().to_string())
.filter(|text| !text.trim().is_empty())
.map(|text| json!({ "type": "reasoning_text", "text": text }))
.collect(),
)
}
/// Writes raw thinking onto a Responses reasoning item without clobbering an
/// existing provider-owned summary or content.
pub(crate) fn apply_openai_responses_reasoning_text(item: &mut Map<String, Value>, text: &str) {
if text.trim().is_empty() {
return;
}
if reasoning_item_field_is_empty(item.get("content")) {
let content = openai_responses_reasoning_text_parts(std::iter::once(text));
item.insert("content".to_string(), content);
}
// `summary` stays a valid (empty) array so the item keeps its documented
// shape; a provider-supplied summary is preserved as-is.
item.entry("summary".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
}
fn reasoning_item_field_is_empty(value: Option<&Value>) -> bool {
match value {
None | Some(Value::Null) => true,
Some(Value::Array(parts)) => parts.is_empty(),
Some(Value::String(text)) => text.trim().is_empty(),
_ => false,
}
}
/// Repairs legacy/non-OpenAI message IDs in a Responses request in place.
///
/// Aether versions before the `msg_` contract emitted IDs such as
@@ -164,6 +215,28 @@ pub fn normalize_openai_responses_message_item_ids(body: &mut Value) -> usize {
repaired
}
pub(crate) fn normalize_openai_responses_call_ids(body: &mut Value) {
let Some(input) = body.get_mut("input") else {
return;
};
let items = match input {
Value::Array(items) => items.as_mut_slice(),
Value::Object(_) => std::slice::from_mut(input),
_ => return,
};
for item in items {
let Some(Value::String(call_id)) = item.get_mut("call_id") else {
continue;
};
if call_id.chars().take(65).count() > 64 {
*call_id = format!(
"call_{}",
URL_SAFE_NO_PAD.encode(Sha256::digest(call_id.as_bytes()))
);
}
}
}
/// Removes reasoning history items that cannot be replayed against an OpenAI Responses backend.
///
/// Reasoning IDs are opaque provider references and must never be repaired by changing their
@@ -234,6 +307,14 @@ fn openai_responses_reasoning_item_is_replayable(
{
return true;
}
if policy == OpenAiResponsesReasoningReplayPolicy::XaiEncrypted
&& object
.get("encrypted_content")
.and_then(Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
{
return true;
}
let Some(id) = object
.get("id")
.and_then(Value::as_str)
@@ -325,8 +406,9 @@ mod tests {
use super::{
decode_gemini_tool_signature_carrier, encode_gemini_tool_signature_carrier_with_direction,
normalize_openai_responses_message_item_ids, openai_responses_message_item_id,
openai_responses_request_operation, openai_responses_synthetic_reasoning_item_id,
normalize_openai_responses_call_ids, normalize_openai_responses_message_item_ids,
openai_responses_message_item_id, openai_responses_request_operation,
openai_responses_synthetic_reasoning_item_id,
strip_incompatible_openai_responses_reasoning_items,
strip_incompatible_openai_responses_reasoning_items_with_policy,
GeminiToolSignatureCarrierDirection, OpenAiResponsesReasoningReplayPolicy,
@@ -334,6 +416,36 @@ mod tests {
OPENAI_RESPONSES_OPERATION_COMPACT,
};
#[test]
fn xai_encrypted_replay_accepts_native_ids_but_excludes_foreign_carriers() {
let body = serde_json::json!({"input": [
{"type": "reasoning", "id": "native-xai-id", "encrypted_content": "opaque-xai-state"},
{"type": "reasoning", "encrypted_content": "opaque-idless-state"},
{"type": "reasoning", "id": "rs_foreign", "encrypted_content": "cpa-gemini-responses-carrier-v1:foreign"},
{"type": "reasoning", "id": "foreign-id", "summary": []}
]});
let mut xai = body.clone();
assert_eq!(
super::strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut xai,
"openai:responses",
super::OpenAiResponsesReasoningReplayPolicy::XaiEncrypted,
),
2
);
assert_eq!(xai["input"].as_array().unwrap().len(), 2);
assert_eq!(xai["input"][0], body["input"][0]);
assert_eq!(xai["input"][1], body["input"][1]);
let mut openai = body;
assert_eq!(
super::strip_incompatible_openai_responses_reasoning_items(
&mut openai,
"openai:responses"
),
4
);
}
#[test]
fn gemini_tool_signature_carrier_roundtrips_direction_and_exact_value() {
let signature = " opaque-signature-with-padding== ";
@@ -419,6 +531,33 @@ mod tests {
assert_ne!(first, other);
}
#[test]
fn reasoning_text_parts_put_raw_thinking_in_content_only() {
let content = super::openai_responses_reasoning_text_parts(["raw chain"]);
assert_eq!(
content,
json!([{ "type": "reasoning_text", "text": "raw chain" }])
);
let mut item = serde_json::Map::new();
super::apply_openai_responses_reasoning_text(&mut item, "raw chain");
assert_eq!(item["content"], content);
// Never mirrored onto `summary`: clients rendering both would repeat it.
assert_eq!(item["summary"], json!([]));
item.insert(
"summary".to_string(),
json!([{ "type": "summary_text", "text": "kept" }]),
);
item.insert("content".to_string(), json!([]));
super::apply_openai_responses_reasoning_text(&mut item, "replacement");
assert_eq!(
item["content"],
json!([{ "type": "reasoning_text", "text": "replacement" }])
);
assert_eq!(item["summary"][0]["text"], "kept");
}
#[test]
fn synthetic_message_item_ids_are_stable_and_start_with_msg() {
let first = openai_responses_message_item_id("1c938e58-32a8-4d28-9c34-538d78076895", 0);
@@ -430,6 +569,77 @@ mod tests {
assert_ne!(first, other);
}
#[test]
fn normalizes_long_call_ids_stably_without_changing_item_ids_or_payloads() {
let long_id = format!("call_{}", "a".repeat(78));
let other_id = format!("{long_id}b");
let arguments = json!({"call_id": long_id}).to_string();
let mut body = json!({"input": [
{"type": "function_call", "id": "fc_provider", "call_id": long_id, "name": "lookup", "arguments": arguments},
{"type": "function_call_output", "call_id": long_id, "output": {"call_id": long_id}},
{"type": "custom_tool_call", "call_id": other_id, "name": "patch", "input": long_id},
{"type": "custom_tool_call_output", "call_id": other_id, "output": "done"}
]});
normalize_openai_responses_call_ids(&mut body);
let first_id = body["input"][0]["call_id"].as_str().expect("first call ID");
let second_id = body["input"][2]["call_id"]
.as_str()
.expect("second call ID");
for call_id in [first_id, second_id] {
assert!(call_id.len() <= 64);
assert!(call_id.chars().all(
|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
));
}
assert_ne!(first_id, second_id);
assert_eq!(body["input"][1]["call_id"], first_id);
assert_eq!(body["input"][3]["call_id"], second_id);
assert_eq!(body["input"][0]["id"], "fc_provider");
assert_eq!(body["input"][0]["arguments"], arguments);
assert_eq!(body["input"][1]["output"]["call_id"], long_id);
assert_eq!(body["input"][2]["input"], long_id);
let mut continuation = json!({"input": {
"type": "function_call_output", "call_id": long_id, "output": "later"
}});
normalize_openai_responses_call_ids(&mut continuation);
assert_eq!(continuation["input"]["call_id"], first_id);
let once = body.clone();
normalize_openai_responses_call_ids(&mut body);
assert_eq!(body, once);
}
#[test]
fn call_id_normalization_preserves_valid_boundaries_and_non_item_data() {
let mut body = json!({"input": [
{"type": "function_call", "call_id": "call_short"},
{"type": "function_call", "call_id": "a".repeat(64)},
{"type": "function_call", "call_id": "\u{00e9}".repeat(64)},
{"type": "message", "content": [{"call_id": "a".repeat(83)}]},
{"type": "function_call_output", "call_id": null},
{"type": "function_call_output", "call_id": 42},
null
]});
let unchanged = body.clone();
normalize_openai_responses_call_ids(&mut body);
assert_eq!(body, unchanged);
for input in [json!("text"), json!(null)] {
let mut body = json!({"input": input});
let unchanged = body.clone();
normalize_openai_responses_call_ids(&mut body);
assert_eq!(body, unchanged);
}
for call_id in ["a".repeat(65), "\u{00e9}".repeat(65)] {
let mut body = json!({"input": [{"type": "function_call", "call_id": call_id}]});
normalize_openai_responses_call_ids(&mut body);
assert!(body["input"][0]["call_id"].as_str().expect("call ID").len() <= 64);
}
}
#[test]
fn normalizes_legacy_message_ids_but_preserves_valid_ids() {
let mut body = json!({
@@ -2,7 +2,7 @@ use std::collections::{BTreeMap, VecDeque};
use serde_json::{json, Map, Value};
use super::encode_tool_result_error;
use super::{apply_openai_responses_reasoning_text, encode_tool_result_error};
use crate::{
formats::context::FormatContext,
@@ -702,14 +702,7 @@ fn canonical_thinking_to_responses_reasoning_item(
.unwrap_or_default();
item.remove("item_type");
item.insert("type".to_string(), Value::String("reasoning".to_string()));
if !text.trim().is_empty() {
item.entry("summary".to_string()).or_insert_with(|| {
json!([{
"type": "summary_text",
"text": text,
}])
});
}
apply_openai_responses_reasoning_text(&mut item, text);
if let Some(value) = encrypted_content.filter(|value| !value.is_empty()) {
item.insert(
"encrypted_content".to_string(),
@@ -6,8 +6,9 @@ use std::{
use serde_json::{json, Map, Value};
use super::{
encode_gemini_tool_signature_carrier, encode_tool_result_error,
history::record_converted_response_history, openai_responses_synthetic_reasoning_item_id,
apply_openai_responses_reasoning_text, encode_gemini_tool_signature_carrier,
encode_tool_result_error, history::record_converted_response_history,
openai_responses_synthetic_reasoning_item_id,
};
use crate::{
@@ -113,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();
@@ -142,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 }),
);
}
}
@@ -217,15 +237,7 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo
Value::String(encrypted_content.clone()),
);
}
if !text.trim().is_empty() {
item.insert(
"summary".to_string(),
Value::Array(vec![json!({
"type": "summary_text",
"text": text,
})]),
);
}
apply_openai_responses_reasoning_text(&mut item, text);
output.push(Value::Object(item));
}
CanonicalContentBlock::ToolUse {
@@ -792,6 +804,65 @@ mod tests {
);
}
#[test]
fn responses_response_builder_puts_raw_thinking_in_content_only() {
let response = CanonicalResponse {
id: "resp_think".to_string(),
model: "deepseek-reasoner".to_string(),
content: vec![
CanonicalContentBlock::Thinking {
text: "first add one to one".to_string(),
signature: None,
encrypted_content: None,
extensions: BTreeMap::new(),
},
CanonicalContentBlock::Text {
text: "2".to_string(),
extensions: BTreeMap::new(),
},
],
outputs: Vec::new(),
stop_reason: Some(CanonicalStopReason::EndTurn),
usage: None,
extensions: BTreeMap::new(),
};
let body = to_raw(&response, &json!({}), false);
let item = &body["output"][0];
assert_eq!(item["type"], "reasoning");
assert_eq!(item["content"][0]["type"], "reasoning_text");
assert_eq!(item["content"][0]["text"], "first add one to one");
assert_eq!(item["summary"], json!([]));
assert!(!item["content"].is_null());
assert_eq!(body["output"][1]["type"], "message");
assert_eq!(body["output"][1]["content"][0]["text"], "2");
}
#[test]
fn responses_response_parser_prefers_content_over_summary_for_raw_reasoning() {
let body = json!({
"id": "resp_test",
"model": "gpt-5",
"status": "completed",
"output": [{
"type": "reasoning",
"id": "rs_1",
"status": "completed",
"summary": [{"type": "summary_text", "text": "short summary"}],
"content": [{"type": "reasoning_text", "text": "full chain of thought"}]
}]
});
let canonical = from_raw(&body).expect("response should parse");
assert!(matches!(
canonical.content.first(),
Some(CanonicalContentBlock::Thinking { text, .. })
if text == "full chain of thought"
));
}
#[test]
fn responses_response_parser_preserves_encrypted_reasoning_without_summary() {
let body = json!({
@@ -879,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"})
);
}
}
@@ -0,0 +1,914 @@
use serde_json::{json, Map, Value};
const XAI_RESPONSES_UNSUPPORTED_BODY_FIELDS: &[&str] = &[
"previous_response_id",
"prompt_cache_retention",
"safety_identifier",
"stream_options",
"stop",
"metadata",
];
const XAI_WEB_SEARCH_TOOL_TYPE: &str = "web_search";
const XAI_IMAGE_GENERATION_TOOL_TYPE: &str = "image_generation";
const XAI_TOOL_SEARCH_TOOL_TYPE: &str = "tool_search";
const XAI_GROK_IMAGE_GENERATION_MIN: XaiGrokVersion = XaiGrokVersion { major: 4, minor: 6 };
#[derive(Clone, Copy)]
struct XaiGrokVersion {
major: i32,
minor: i32,
}
pub fn apply_xai_upstream_payload_edits(
body: &mut Value,
provider_type: &str,
provider_api_format: &str,
) {
apply_xai_upstream_payload_edits_with_client(
body,
provider_type,
provider_api_format,
None,
None,
);
}
pub fn apply_xai_upstream_payload_edits_with_client(
body: &mut Value,
provider_type: &str,
provider_api_format: &str,
client_api_format: Option<&str>,
client_body: Option<&Value>,
) {
if !provider_type.trim().eq_ignore_ascii_case("xai") {
return;
}
normalize_xai_image_refs(body);
if crate::is_openai_responses_family_format(provider_api_format) {
restore_xai_web_search_from_client(body, client_api_format, client_body);
sanitize_xai_responses_body(body);
}
}
fn sanitize_xai_responses_body(body: &mut Value) {
let Some(object) = body.as_object_mut() else {
return;
};
for field in XAI_RESPONSES_UNSUPPORTED_BODY_FIELDS {
object.remove(*field);
}
let keep_image_generation = object
.get("model")
.and_then(Value::as_str)
.is_some_and(xai_supports_native_image_generation);
normalize_xai_tool_arrays(object, keep_image_generation);
rewrite_xai_web_search_tool_choice(object);
prune_xai_orphaned_tool_choice(object);
rewrite_xai_image_generation_tool_choice(object);
drop_tool_choice_without_tools(object);
strip_unsupported_reasoning_effort(object);
sanitize_xai_input_encrypted_content(object);
}
fn restore_xai_web_search_from_client(
body: &mut Value,
client_api_format: Option<&str>,
client_body: Option<&Value>,
) {
let Some(client_api_format) = client_api_format else {
return;
};
let Some(client_body) = client_body else {
return;
};
if !client_requests_web_search(client_api_format, client_body) {
return;
}
ensure_xai_web_search_tool(body);
// Claude names a hosted tool in tool_choice just like a client function.
// Resolve that name against the original declaration, never by name alone.
if crate::normalize_api_format_alias(client_api_format) == "claude:messages" {
let choice = &client_body["tool_choice"];
if choice["type"] == "tool"
&& choice["name"].as_str().is_some_and(|name| {
request_tools(client_body)
.iter()
.any(|tool| is_web_search_tool(tool) && tool_name(tool) == Some(name))
})
{
body["tool_choice"] = json!({"type": XAI_WEB_SEARCH_TOOL_TYPE});
}
}
}
fn client_requests_web_search(client_api_format: &str, client_body: &Value) -> bool {
let format = crate::normalize_api_format_alias(client_api_format);
match format.as_str() {
"openai:chat" => {
object_has_non_null_field(client_body, "web_search_options")
|| request_tools(client_body).iter().any(is_web_search_tool)
}
"claude:messages" => request_tools(client_body).iter().any(is_web_search_tool),
"gemini:generate_content" => gemini_request_has_google_search(client_body),
_ => false,
}
}
fn gemini_request_has_google_search(body: &Value) -> bool {
request_tools(body).iter().any(|tool| {
tool.get("googleSearch").is_some()
|| tool.get("google_search").is_some()
|| tool
.get("googleSearchRetrieval")
.is_some_and(|value| !value.is_null())
})
}
fn object_has_non_null_field(body: &Value, field: &str) -> bool {
body.get(field).is_some_and(|value| !value.is_null())
}
fn ensure_xai_web_search_tool(body: &mut Value) {
let Some(object) = body.as_object_mut() else {
return;
};
if tools_array(object).iter().any(is_web_search_tool) {
return;
}
let tools = object
.entry("tools".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if let Some(tools) = tools.as_array_mut() {
tools.push(json!({ "type": XAI_WEB_SEARCH_TOOL_TYPE }));
}
}
fn normalize_xai_tool_arrays(object: &mut Map<String, Value>, keep_image_generation: bool) {
if let Some(tools) = object.get_mut("tools").and_then(Value::as_array_mut) {
*tools = normalize_xai_tool_list(tools, keep_image_generation);
if tools.is_empty() {
object.remove("tools");
}
}
let Some(input) = object.get_mut("input").and_then(Value::as_array_mut) else {
return;
};
for item in input {
let Some(item_object) = item.as_object_mut() else {
continue;
};
if item_object.get("type").and_then(Value::as_str) != Some("additional_tools") {
continue;
}
if let Some(tools) = item_object.get_mut("tools").and_then(Value::as_array_mut) {
*tools = normalize_xai_tool_list(tools, keep_image_generation);
}
}
}
fn normalize_xai_tool_list(tools: &[Value], keep_image_generation: bool) -> Vec<Value> {
tools
.iter()
.filter_map(|tool| normalize_xai_tool(tool, keep_image_generation))
.collect()
}
fn normalize_xai_tool(tool: &Value, keep_image_generation: bool) -> Option<Value> {
let Some(object) = tool.as_object() else {
return Some(tool.clone());
};
let tool_type = tool_type(tool).unwrap_or("function");
if tool_type == XAI_TOOL_SEARCH_TOOL_TYPE {
return None;
}
if tool_type == XAI_IMAGE_GENERATION_TOOL_TYPE && !keep_image_generation {
return None;
}
if tool_type == "custom" && tool_name(tool).is_some_and(|name| name == "apply_patch") {
return None;
}
let mut next = object.clone();
if tool_type.starts_with("web_search") {
next.insert(
"type".to_string(),
Value::String(XAI_WEB_SEARCH_TOOL_TYPE.to_string()),
);
next.remove("name");
next.remove("external_web_access");
return Some(Value::Object(next));
}
if tool_type == "custom" {
next.insert("type".to_string(), Value::String("function".to_string()));
if let Some(custom) = next.remove("custom") {
if let Some(custom_object) = custom.as_object() {
for (key, value) in custom_object {
next.entry(key.clone()).or_insert_with(|| value.clone());
}
}
}
if !next.contains_key("parameters") {
next.insert(
"parameters".to_string(),
json!({"type": "object", "properties": {}}),
);
}
return Some(Value::Object(next));
}
if tool_type == "function" && !next.contains_key("parameters") {
next.insert(
"parameters".to_string(),
json!({"type": "object", "properties": {}}),
);
}
Some(Value::Object(next))
}
fn rewrite_xai_web_search_tool_choice(object: &mut Map<String, Value>) {
let Some(choice) = object.get("tool_choice").cloned() else {
return;
};
let Some(choice_type) = choice.as_object().and_then(|value| {
value
.get("type")
.and_then(Value::as_str)
.map(str::trim)
.map(str::to_ascii_lowercase)
}) else {
return;
};
if is_web_search_choice_type(&choice_type) {
object.insert(
"tool_choice".to_string(),
json!({
"type": "allowed_tools",
"mode": "required",
"tools": [{ "type": XAI_WEB_SEARCH_TOOL_TYPE }]
}),
);
}
}
fn rewrite_xai_image_generation_tool_choice(object: &mut Map<String, Value>) {
let has_image_generation = tools_array(object)
.iter()
.any(|tool| tool_type(tool).is_some_and(|value| value == XAI_IMAGE_GENERATION_TOOL_TYPE));
if !has_image_generation {
return;
}
let Some(choice) = object.get("tool_choice").cloned() else {
return;
};
// xAI's allowed_tools schema cannot contain image_generation. Preserve an
// image-only restriction before filtering image entries out of mixed lists.
let image_only = is_allowed_tools_image_generation_only(&choice);
if choice["type"] == XAI_IMAGE_GENERATION_TOOL_TYPE || image_only {
let mode = if image_only && choice["mode"] == "auto" {
"auto"
} else {
"required"
};
keep_only_image_generation_tools(object);
object.insert("tool_choice".to_string(), Value::String(mode.to_string()));
} else if choice["type"] == "allowed_tools" {
filter_image_generation_from_allowed_tools(object);
}
}
fn is_allowed_tools_image_generation_only(choice: &Value) -> bool {
let Some(object) = choice.as_object() else {
return false;
};
if object.get("type").and_then(Value::as_str) != Some("allowed_tools") {
return false;
}
let Some(tools) = object.get("tools").and_then(Value::as_array) else {
return false;
};
!tools.is_empty()
&& tools.iter().all(|tool| {
tool_type(tool).is_some_and(|value| value == XAI_IMAGE_GENERATION_TOOL_TYPE)
})
}
fn keep_only_image_generation_tools(object: &mut Map<String, Value>) {
let Some(tools) = object.get_mut("tools").and_then(Value::as_array_mut) else {
return;
};
tools.retain(|tool| {
tool_type(tool).is_some_and(|value| value == XAI_IMAGE_GENERATION_TOOL_TYPE)
});
}
fn filter_image_generation_from_allowed_tools(object: &mut Map<String, Value>) {
let Some(choice) = object.get_mut("tool_choice").and_then(Value::as_object_mut) else {
return;
};
let Some(tools) = choice.get_mut("tools").and_then(Value::as_array_mut) else {
return;
};
tools
.retain(|tool| tool_type(tool).is_none_or(|value| value != XAI_IMAGE_GENERATION_TOOL_TYPE));
}
fn is_web_search_choice_type(value: &str) -> bool {
value == XAI_WEB_SEARCH_TOOL_TYPE || value.starts_with("web_search")
}
fn prune_xai_orphaned_tool_choice(object: &mut Map<String, Value>) {
let available = collect_available_tool_choice_keys(object);
let Some(choice) = object.get("tool_choice").cloned() else {
return;
};
if choice.as_str().is_some() {
return;
}
let Some(choice_object) = choice.as_object() else {
object.remove("tool_choice");
return;
};
let choice_type = choice_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if choice_type == "allowed_tools" {
let Some(allowed) = choice_object.get("tools").and_then(Value::as_array) else {
object.remove("tool_choice");
return;
};
let kept = allowed
.iter()
.filter(|tool| tool_matches_available(tool, &available))
.cloned()
.collect::<Vec<_>>();
if kept.is_empty() {
object.remove("tool_choice");
return;
}
if let Some(choice) = object.get_mut("tool_choice").and_then(Value::as_object_mut) {
choice.insert("tools".to_string(), Value::Array(kept));
}
return;
}
if choice_type.is_empty() {
return;
}
if !tool_matches_available(&choice, &available) {
object.remove("tool_choice");
}
}
fn collect_available_tool_choice_keys(object: &Map<String, Value>) -> Vec<ToolChoiceKey> {
let mut keys = Vec::new();
collect_tool_choice_keys(tools_array(object), &mut keys);
if let Some(input) = object.get("input").and_then(Value::as_array) {
for item in input {
if item.get("type").and_then(Value::as_str) == Some("additional_tools") {
collect_tool_choice_keys(
item.get("tools")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]),
&mut keys,
);
}
}
}
keys
}
fn collect_tool_choice_keys(tools: &[Value], keys: &mut Vec<ToolChoiceKey>) {
for tool in tools {
let Some(tool_type) = tool_type(tool) else {
continue;
};
if matches!(tool_type, "function" | "custom") {
if let Some(name) = tool_name(tool) {
keys.push(ToolChoiceKey::Named {
name: name.to_ascii_lowercase(),
});
}
continue;
}
keys.push(ToolChoiceKey::Hosted(tool_type.to_ascii_lowercase()));
}
}
fn tool_matches_available(choice: &Value, available: &[ToolChoiceKey]) -> bool {
let Some(object) = choice.as_object() else {
return false;
};
let choice_type = object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(choice_type.as_str(), "function" | "custom" | "tool") {
let Some(name) = tool_choice_name(object) else {
return false;
};
return available.iter().any(|key| {
matches!(
key,
ToolChoiceKey::Named { name: available_name, .. }
if available_name == &name.to_ascii_lowercase()
)
});
}
if is_web_search_choice_type(&choice_type) {
return available.iter().any(
|key| matches!(key, ToolChoiceKey::Hosted(value) if value == XAI_WEB_SEARCH_TOOL_TYPE),
);
}
available
.iter()
.any(|key| matches!(key, ToolChoiceKey::Hosted(value) if value == &choice_type))
}
#[derive(Clone, Debug)]
enum ToolChoiceKey {
Named { name: String },
Hosted(String),
}
fn drop_tool_choice_without_tools(object: &mut Map<String, Value>) {
if xai_request_has_tools(object) {
return;
}
object.remove("tools");
object.remove("tool_choice");
object.remove("parallel_tool_calls");
}
fn xai_request_has_tools(object: &Map<String, Value>) -> bool {
if !tools_array(object).is_empty() {
return true;
}
object
.get("input")
.and_then(Value::as_array)
.into_iter()
.flatten()
.any(|item| {
item.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value == "additional_tools")
&& item
.get("tools")
.and_then(Value::as_array)
.is_some_and(|tools| !tools.is_empty())
})
}
fn strip_unsupported_reasoning_effort(object: &mut Map<String, Value>) {
let model = object
.get("model")
.and_then(Value::as_str)
.unwrap_or_default();
if xai_model_supports_reasoning_effort(model) {
return;
}
let Some(reasoning) = object.get_mut("reasoning") else {
return;
};
let Some(reasoning_object) = reasoning.as_object_mut() else {
return;
};
reasoning_object.remove("effort");
if reasoning_object.is_empty() {
object.remove("reasoning");
}
}
pub fn xai_model_supports_reasoning_effort(model: &str) -> bool {
let lowered = model.trim().to_ascii_lowercase();
let name = lowered.rsplit('/').next().unwrap_or(lowered.as_str());
if name.is_empty() || name.contains("non-reasoning") || name.contains("imagine") {
return false;
}
name.starts_with("grok-3-mini")
|| name.starts_with("grok-4")
|| name.starts_with("grok-build")
|| name.starts_with("grok-composer")
}
pub fn xai_supports_native_image_generation(model: &str) -> bool {
let lowered = model.trim().to_ascii_lowercase();
let name = lowered.rsplit('/').next().unwrap_or(lowered.as_str());
let Some(rest) = name.strip_prefix("grok-") else {
return false;
};
if rest == "4.20" || rest.starts_with("4.20-") {
return false;
}
parse_grok_version_prefix(rest).is_some_and(grok_version_at_least_image_generation)
}
fn parse_grok_version_prefix(rest: &str) -> Option<XaiGrokVersion> {
let major_len = rest
.find(|ch: char| !ch.is_ascii_digit())
.unwrap_or(rest.len());
if major_len == 0 {
return None;
}
let major = rest[..major_len].parse().ok()?;
if major_len == rest.len() || !rest[major_len..].starts_with('.') {
return Some(XaiGrokVersion { major, minor: -1 });
}
let after_dot = &rest[major_len + 1..];
let minor_len = after_dot
.find(|ch: char| !ch.is_ascii_digit())
.unwrap_or(after_dot.len());
if minor_len == 0 {
return Some(XaiGrokVersion { major, minor: -1 });
}
let minor = after_dot[..minor_len].parse().ok()?;
Some(XaiGrokVersion { major, minor })
}
fn grok_version_at_least_image_generation(version: XaiGrokVersion) -> bool {
let minor = if version.minor < 0 { 0 } else { version.minor };
(version.major, minor)
>= (
XAI_GROK_IMAGE_GENERATION_MIN.major,
XAI_GROK_IMAGE_GENERATION_MIN.minor,
)
}
fn sanitize_xai_input_encrypted_content(object: &mut Map<String, Value>) {
let Some(input) = object.get_mut("input").and_then(Value::as_array_mut) else {
return;
};
let mut kept = Vec::new();
for item in input.iter() {
let Some(item_object) = item.as_object() else {
kept.push(item.clone());
continue;
};
let item_type = item_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if item_type != "reasoning" && item_type != "compaction" {
kept.push(item.clone());
continue;
}
let Some(encrypted) = item_object.get("encrypted_content") else {
kept.push(item.clone());
continue;
};
let valid = encrypted
.as_str()
.is_some_and(|value| !value.trim().is_empty());
if valid {
kept.push(item.clone());
continue;
}
if item_type == "compaction" {
continue;
}
let mut next = item_object.clone();
next.remove("encrypted_content");
kept.push(Value::Object(next));
}
*input = kept;
}
fn normalize_xai_image_refs(value: &mut Value) {
match value {
Value::Object(object) => {
for key in ["image", "images", "reference_images"] {
match object.get_mut(key) {
Some(Value::Array(items)) if key != "image" => {
for item in items {
normalize_xai_image_ref(item);
}
}
Some(item) if key == "image" => normalize_xai_image_ref(item),
_ => {}
}
}
for child in object.values_mut() {
normalize_xai_image_refs(child);
}
}
Value::Array(items) => {
for item in items {
normalize_xai_image_refs(item);
}
}
_ => {}
}
}
fn normalize_xai_image_ref(value: &mut Value) {
let Some(object) = value.as_object_mut() else {
return;
};
let original_url = object
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let image_url = object.get("image_url").cloned();
let resolved_url = original_url.clone().or_else(|| match image_url.as_ref() {
Some(Value::String(url)) => {
let trimmed = url.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
Some(Value::Object(inner)) => inner
.get("url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
_ => None,
});
let Some(url) = resolved_url else {
return;
};
if original_url.as_deref() == Some(url.as_str()) && image_url.is_none() {
return;
}
object.insert("url".to_string(), Value::String(url));
object.remove("image_url");
}
fn request_tools(body: &Value) -> &[Value] {
body.get("tools")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[])
}
fn tools_array(object: &Map<String, Value>) -> &[Value] {
object
.get("tools")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[])
}
fn tool_type(tool: &Value) -> Option<&str> {
tool.get("type").and_then(Value::as_str).map(str::trim)
}
fn tool_name(tool: &Value) -> Option<&str> {
tool.get("name")
.and_then(Value::as_str)
.or_else(|| {
tool.get("function")
.and_then(Value::as_object)
.and_then(|value| value.get("name"))
.and_then(Value::as_str)
})
.or_else(|| {
tool.get("custom")
.and_then(Value::as_object)
.and_then(|value| value.get("name"))
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn tool_choice_name(choice: &Map<String, Value>) -> Option<&str> {
choice
.get("name")
.and_then(Value::as_str)
.or_else(|| {
choice
.get("function")
.and_then(Value::as_object)
.and_then(|value| value.get("name"))
.and_then(Value::as_str)
})
.or_else(|| {
choice
.get("custom")
.and_then(Value::as_object)
.and_then(|value| value.get("name"))
.and_then(Value::as_str)
})
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn is_web_search_tool(tool: &Value) -> bool {
tool_type(tool).is_some_and(is_web_search_choice_type)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
apply_xai_upstream_payload_edits, apply_xai_upstream_payload_edits_with_client,
xai_model_supports_reasoning_effort, xai_supports_native_image_generation,
XAI_RESPONSES_UNSUPPORTED_BODY_FIELDS,
};
#[test]
fn xai_responses_edits_strip_continuation_fields_and_empty_tool_choice() {
let mut body = json!({
"model": "grok-4.6",
"input": "hello",
"previous_response_id": "resp_123",
"prompt_cache_retention": "24h",
"safety_identifier": "user-1",
"stream_options": {"include_obfuscation": true},
"stop": ["END"],
"metadata": {
"user_id": "{\"device_id\":\"dev-1\",\"account_uuid\":\"acct-1\",\"session_id\":\"sess-1\"}"
},
"include": ["reasoning.encrypted_content", "file_search_call.results"],
"tool_choice": "auto",
"parallel_tool_calls": true,
"tools": []
});
apply_xai_upstream_payload_edits(&mut body, "xai", "openai:responses");
for field in XAI_RESPONSES_UNSUPPORTED_BODY_FIELDS {
assert!(body.get(*field).is_none(), "{field} should be stripped");
}
assert!(body.get("tool_choice").is_none());
assert!(body.get("parallel_tool_calls").is_none());
assert!(body.get("tools").is_none());
assert_eq!(
body["include"],
json!(["reasoning.encrypted_content", "file_search_call.results"])
);
assert_eq!(body["model"], "grok-4.6");
assert_eq!(body["input"], "hello");
}
#[test]
fn xai_responses_edits_keep_reasoning_effort_for_thinking_models() {
let mut body = json!({
"model": "grok-4.6",
"reasoning": {"effort": "high", "summary": "auto"}
});
apply_xai_upstream_payload_edits(&mut body, "xai", "openai:responses");
assert_eq!(body["reasoning"]["effort"], "high");
assert_eq!(body["reasoning"]["summary"], "auto");
}
#[test]
fn xai_responses_edits_strip_reasoning_effort_for_non_thinking_models() {
let mut body = json!({
"model": "grok-4.20-0309-non-reasoning",
"reasoning": {"effort": "high"}
});
apply_xai_upstream_payload_edits(&mut body, "xai", "openai:responses");
assert!(body.get("reasoning").is_none());
assert!(!xai_model_supports_reasoning_effort(
"grok-4.20-0309-non-reasoning"
));
assert!(xai_model_supports_reasoning_effort("xai/grok-4.5"));
assert!(!xai_model_supports_reasoning_effort("grok-imagine-image"));
}
#[test]
fn xai_hosted_tool_choice_rewrites_web_search_and_image_generation() {
let mut web_search = json!({
"model": "grok-4.6",
"tools": [{"type": "web_search_preview", "name": "web_search"}],
"tool_choice": {"type": "web_search"}
});
apply_xai_upstream_payload_edits(&mut web_search, "xai", "openai:responses");
assert_eq!(web_search["tools"][0]["type"], "web_search");
assert!(web_search["tools"][0].get("name").is_none());
assert_eq!(web_search["tool_choice"]["type"], "allowed_tools");
assert_eq!(web_search["tool_choice"]["mode"], "required");
assert_eq!(web_search["tool_choice"]["tools"][0]["type"], "web_search");
let mut image = json!({
"model": "grok-4.6",
"tools": [
{"type": "web_search"},
{"type": "image_generation", "action": "generate"}
],
"tool_choice": {"type": "image_generation"}
});
apply_xai_upstream_payload_edits(&mut image, "xai", "openai:responses");
assert_eq!(image["tool_choice"], "required");
assert_eq!(image["tools"].as_array().map(Vec::len), Some(1));
assert_eq!(image["tools"][0]["type"], "image_generation");
}
#[test]
fn xai_strips_image_generation_on_older_conversation_models() {
let mut body = json!({
"model": "grok-4.5",
"tools": [
{"type": "function", "name": "lookup", "parameters": {"type": "object"}},
{"type": "image_generation"}
],
"tool_choice": {"type": "image_generation"}
});
apply_xai_upstream_payload_edits(&mut body, "xai", "openai:responses");
assert_eq!(body["tools"].as_array().map(Vec::len), Some(1));
assert_eq!(body["tools"][0]["name"], "lookup");
assert!(body.get("tool_choice").is_none());
assert!(xai_supports_native_image_generation("grok-4.6"));
assert!(!xai_supports_native_image_generation("grok-4.20-0309"));
assert!(!xai_supports_native_image_generation("grok-4.5"));
}
#[test]
fn xai_restores_web_search_from_chat_and_claude_clients() {
let mut chat_body = json!({
"model": "grok-4.6",
"input": "search this"
});
apply_xai_upstream_payload_edits_with_client(
&mut chat_body,
"xai",
"openai:responses",
Some("openai:chat"),
Some(&json!({
"messages": [{"role": "user", "content": "news"}],
"web_search_options": {"search_context_size": "high"}
})),
);
assert_eq!(chat_body["tools"][0]["type"], "web_search");
let mut claude_body = json!({
"model": "grok-4.6",
"input": "search this",
"tools": [{
"type": "function",
"name": "lookup",
"parameters": {"type": "object", "properties": {}}
}],
"tool_choice": {"type": "function", "name": "web_search"}
});
apply_xai_upstream_payload_edits_with_client(
&mut claude_body,
"xai",
"openai:responses",
Some("claude:messages"),
Some(&json!({
"tools": [
{"type": "web_search_20250305", "name": "web_search"},
{"name": "lookup", "input_schema": {"type": "object"}}
],
"tool_choice": {"type": "tool", "name": "web_search"}
})),
);
assert!(claude_body["tools"]
.as_array()
.into_iter()
.flatten()
.any(|tool| tool["type"] == "web_search"));
assert_eq!(claude_body["tool_choice"]["type"], "allowed_tools");
}
#[test]
fn xai_image_refs_rewrite_openai_aliases_without_touching_chat_parts() {
let mut body = json!({
"model": "grok-imagine-image",
"prompt": "edit this",
"image": {"image_url": "https://cdn.example/a.png"},
"reference_images": [
{"image_url": {"url": "https://cdn.example/b.png"}}
],
"input": [{
"type": "message",
"content": [{
"type": "image_url",
"image_url": {"url": "https://cdn.example/chat.png"}
}]
}]
});
apply_xai_upstream_payload_edits(&mut body, "xai", "openai:image");
assert_eq!(body["image"]["url"], "https://cdn.example/a.png");
assert!(body["image"].get("image_url").is_none());
assert_eq!(
body["reference_images"][0]["url"],
"https://cdn.example/b.png"
);
assert_eq!(
body["input"][0]["content"][0]["image_url"]["url"],
"https://cdn.example/chat.png"
);
}
#[test]
fn other_providers_are_left_untouched() {
let mut body = json!({
"previous_response_id": "resp_123",
"image": {"image_url": "https://cdn.example/a.png"}
});
apply_xai_upstream_payload_edits(&mut body, "codex", "openai:responses");
assert_eq!(body["previous_response_id"], "resp_123");
assert_eq!(body["image"]["image_url"], "https://cdn.example/a.png");
}
}
@@ -3594,6 +3594,108 @@ mod tests {
.any(|field| field.field == "messages"));
}
/// Gemini runs `googleSearch` server-side, so the only trace of the search
/// is `groundingMetadata`. Clients on the other formats have to receive it
/// as their own native citations or the answer arrives unverifiable.
#[test]
fn gemini_grounding_reaches_every_cross_format_client_as_citations() {
let gemini = grounded_gemini_response();
for target in ["openai:chat", "openai:responses"] {
let converted =
convert_response_pure("gemini:generate_content", target, &gemini).expect(target);
let body = serde_json::to_string(&converted.value).expect("serialize");
let annotations = find_first_array(&converted.value, "annotations")
.unwrap_or_else(|| panic!("{target} dropped the grounding metadata: {body}"));
assert_eq!(
annotations,
&json!([{
"type": "url_citation",
"url": "https://time.gov/",
"title": "time.gov",
"start_index": 0,
"end_index": 9,
}]),
"{target} annotations"
);
}
let converted =
convert_response_pure("gemini:generate_content", "claude:messages", &gemini)
.expect("claude:messages");
let body = serde_json::to_string(&converted.value).expect("serialize");
let citations = find_first_array(&converted.value, "citations")
.unwrap_or_else(|| panic!("claude:messages dropped the grounding metadata: {body}"));
assert_eq!(
citations,
&json!([{
"type": "web_search_result_location",
"url": "https://time.gov/",
"title": "time.gov",
"cited_text": "今天是 2026",
}])
);
}
/// The grounded span is reported in UTF-8 bytes but every target counts
/// characters, so a multi-byte answer must not shift the citation.
#[test]
fn gemini_grounding_offsets_are_converted_from_bytes_to_characters() {
let converted = convert_response_pure(
"gemini:generate_content",
"openai:chat",
&grounded_gemini_response(),
)
.expect("convert");
let annotation =
&find_first_array(&converted.value, "annotations").expect("annotations")[0];
// "今天是 2026 " is 15 bytes but 9 characters.
assert_eq!(annotation["end_index"], json!(9));
}
fn grounded_gemini_response() -> serde_json::Value {
json!({
"responseId": "resp_grounded",
"modelVersion": "gemini-3.8-flash",
"candidates": [{
"index": 0,
"finishReason": "STOP",
"groundingMetadata": {
"webSearchQueries": ["current UTC date"],
"groundingChunks": [{
"web": {"uri": "https://time.gov/", "title": "time.gov"}
}],
"groundingSupports": [{
"segment": {"startIndex": 0, "endIndex": 15},
"groundingChunkIndices": [0]
}]
},
"content": {"parts": [{"text": "今天是 2026 年"}]}
}]
})
}
fn find_first_array<'a>(
value: &'a serde_json::Value,
key: &str,
) -> Option<&'a serde_json::Value> {
match value {
serde_json::Value::Object(object) => {
if let Some(found) = object.get(key).filter(|found| found.is_array()) {
return Some(found);
}
object
.values()
.find_map(|value| find_first_array(value, key))
}
serde_json::Value::Array(items) => {
items.iter().find_map(|item| find_first_array(item, key))
}
_ => None,
}
}
#[test]
fn runtime_responses_to_gemini_rejects_mixed_tools_for_gemini_two() {
let body = json!({
@@ -0,0 +1,113 @@
//! Provider-neutral source citations.
//!
//! Some providers ground an answer server-side (Gemini's native `googleSearch`
//! is the motivating case): the search leaves no client-visible tool call, and
//! the evidence arrives only as provider-specific metadata alongside the text.
//! Dropping it leaves callers with prose that names its sources but nothing
//! they can render, link, or verify.
//!
//! Adapters therefore normalise that metadata into the neutral citation shape
//! below, and each target renders it into its own family's standard shape.
//! Neither side has to learn the other's vocabulary.
use serde_json::{Map, Value};
/// Build one neutral citation.
///
/// `start_index` / `end_index` are character offsets into the answer text —
/// providers that report byte offsets convert before calling. Every field but
/// `url` is optional, because providers routinely ground an answer without
/// anchoring it to a span.
pub(crate) fn canonical_citation(
url: &str,
title: Option<&str>,
start_index: Option<usize>,
end_index: Option<usize>,
cited_text: Option<&str>,
) -> Value {
let mut citation = Map::new();
citation.insert("url".to_string(), Value::String(url.to_string()));
if let Some(title) = title {
citation.insert("title".to_string(), Value::String(title.to_string()));
}
if let Some(start_index) = start_index {
citation.insert("start_index".to_string(), Value::from(start_index as u64));
}
if let Some(end_index) = end_index {
citation.insert("end_index".to_string(), Value::from(end_index as u64));
}
if let Some(cited_text) = cited_text {
citation.insert(
"cited_text".to_string(),
Value::String(cited_text.to_string()),
);
}
Value::Object(citation)
}
fn citation_string<'a>(citation: &'a Value, key: &str) -> Option<&'a str> {
citation
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
/// Render a neutral citation as an OpenAI `url_citation` annotation, the shape
/// both `chat.completions` and `responses` attach to assistant text.
pub(crate) fn canonical_citation_to_openai_annotation(citation: &Value) -> Option<Value> {
let url = citation_string(citation, "url")?;
let mut annotation = Map::new();
annotation.insert(
"type".to_string(),
Value::String("url_citation".to_string()),
);
annotation.insert("url".to_string(), Value::String(url.to_string()));
if let Some(title) = citation_string(citation, "title") {
annotation.insert("title".to_string(), Value::String(title.to_string()));
}
for key in ["start_index", "end_index"] {
if let Some(index) = citation.get(key).and_then(Value::as_u64) {
annotation.insert(key.to_string(), Value::from(index));
}
}
Some(Value::Object(annotation))
}
/// Render a neutral citation as a Claude `web_search_result_location`, the
/// shape Claude puts in a text block's `citations`.
pub(crate) fn canonical_citation_to_claude_citation(citation: &Value) -> Option<Value> {
let url = citation_string(citation, "url")?;
let mut out = Map::new();
out.insert(
"type".to_string(),
Value::String("web_search_result_location".to_string()),
);
out.insert("url".to_string(), Value::String(url.to_string()));
if let Some(title) = citation_string(citation, "title") {
out.insert("title".to_string(), Value::String(title.to_string()));
}
if let Some(cited_text) = citation_string(citation, "cited_text") {
out.insert(
"cited_text".to_string(),
Value::String(cited_text.to_string()),
);
}
Some(Value::Object(out))
}
/// Render every citation that carries a usable URL.
pub(crate) fn canonical_citations_to_openai_annotations(citations: &[Value]) -> Vec<Value> {
citations
.iter()
.filter_map(canonical_citation_to_openai_annotation)
.collect()
}
/// Render every citation that carries a usable URL.
pub(crate) fn canonical_citations_to_claude_citations(citations: &[Value]) -> Vec<Value> {
citations
.iter()
.filter_map(canonical_citation_to_claude_citation)
.collect()
}
@@ -6,6 +6,7 @@ use std::fmt;
/// a base64 field cannot trigger an unchecked allocation before parsing.
pub(crate) const MAX_SYNC_REPORT_BODY_BYTES: usize = 64 * 1024 * 1024;
pub mod citations;
pub mod error_body;
pub mod family;
pub mod image_bridge;
@@ -49,6 +49,10 @@ pub fn resolve_execution_runtime_stream_plan_kind_with_client_surface(
method: &Method,
path: &str,
) -> Option<&'static str> {
let path = path
.strip_prefix("/openai")
.filter(|p| *p == "/v1/videos" || p.starts_with("/v1/videos/"))
.unwrap_or(path);
if route_class != Some("ai_public") {
return None;
}
@@ -181,6 +185,10 @@ pub fn resolve_execution_runtime_sync_plan_kind_with_client_surface(
method: &Method,
path: &str,
) -> Option<&'static str> {
let path = path
.strip_prefix("/openai")
.filter(|p| *p == "/v1/videos" || p.starts_with("/v1/videos/"))
.unwrap_or(path);
if route_class != Some("ai_public") {
return None;
}
@@ -206,7 +214,10 @@ pub fn resolve_execution_runtime_sync_plan_kind_with_client_surface(
if route_family == Some("openai")
&& route_kind == Some("video")
&& *method == Method::POST
&& path == "/v1/videos"
&& matches!(
path,
"/v1/videos" | "/v1/videos/generations" | "/v1/videos/edits" | "/v1/videos/extensions"
)
{
return Some(OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND);
}
@@ -17,11 +17,23 @@ use crate::formats::openai::responses::codex::{
apply_codex_openai_responses_chat_body_edits, apply_codex_openai_responses_special_body_edits,
apply_openai_responses_compact_special_body_edits,
};
use crate::formats::openai::responses::xai::apply_xai_upstream_payload_edits_with_client;
use crate::formats::shared::standard_normalize::{
build_local_openai_chat_request_body_with_model_directives,
is_claude_messages_shaped_body_on_openai_chat_endpoint,
};
/// Tool schema preservation is a format-conversion policy, shared by the
/// standard matrix and provider-aware Chat/Responses entry points.
pub(super) fn preserves_gemini_tool_schemas(
provider_type: &str,
provider_api_format: &str,
) -> bool {
provider_type.trim().eq_ignore_ascii_case("antigravity")
&& aether_ai_formats::normalize_api_format_alias(provider_api_format)
== "gemini:generate_content"
}
#[allow(clippy::too_many_arguments)]
pub fn build_standard_request_body(
body_json: &Value,
@@ -121,10 +133,17 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers_and
enable_model_directives: bool,
reasoning_replay_policy: crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy,
) -> Option<Value> {
let reasoning_replay_policy = if provider_type.trim().eq_ignore_ascii_case("xai") {
crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy::XaiEncrypted
} else {
reasoning_replay_policy
};
let mut format_context = FormatContext::default()
.with_mapped_model(mapped_model)
.with_request_path(request_path)
.with_upstream_stream(upstream_is_stream);
format_context.preserve_gemini_tool_schemas =
preserves_gemini_tool_schemas(provider_type, provider_api_format);
if let Some(history_scope) = user_api_key_id {
format_context = format_context.with_history_scope(history_scope);
}
@@ -133,13 +152,27 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers_and
client_api_format,
provider_api_format,
);
// DeepSeek's Responses continuation state is opaque. Parsing a same-wire-format
// request through the canonical model would discard its id-less `reasoning_text`
// items and future provider-owned fields even though no conversion is required.
// Keep that provider-specific route wire-preserving, while retaining canonical
// normalization for ordinary OpenAI Responses and for Responses/Compact
// cross-format conversions.
let mut provider_request_body = if is_wire_preserving_deepseek_responses_hop(
// Keep the specialized OpenAI builders' compatibility/history preprocessing
// when routing them through the provider-aware schema-preserving path.
let antigravity_chat_body = if format_context.preserve_gemini_tool_schemas
&& matches!(
aether_ai_formats::normalize_api_format_alias(source_api_format.as_ref()).as_str(),
"openai:chat" | "openai:responses" | "openai:responses:compact"
) {
Some(
crate::formats::shared::standard_normalize::chat_compatible_body_for_standard_source(
body_json,
source_api_format.as_ref(),
user_api_key_id,
)?,
)
} else {
None
};
// DeepSeek and xAI replay opaque provider state. Preserve their native
// Responses input items: canonical conversion can lose reasoning IDs and
// encrypted-only items even when source and destination formats are equal.
let mut provider_request_body = if is_wire_preserving_responses_hop(
source_api_format.as_ref(),
provider_api_format,
reasoning_replay_policy,
@@ -149,9 +182,13 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers_and
Value::Object(object)
} else {
convert_request(
source_api_format.as_ref(),
if antigravity_chat_body.is_some() {
"openai:chat"
} else {
source_api_format.as_ref()
},
provider_api_format,
body_json,
antigravity_chat_body.as_deref().unwrap_or(body_json),
&format_context,
)
.ok()?
@@ -200,6 +237,13 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers_and
&mut provider_request_body,
provider_api_format,
);
apply_xai_upstream_payload_edits_with_client(
&mut provider_request_body,
provider_type,
provider_api_format,
Some(client_api_format),
Some(body_json),
);
crate::formats::openai::responses::strip_incompatible_openai_responses_reasoning_items_with_policy(
&mut provider_request_body,
provider_api_format,
@@ -224,14 +268,16 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers_and
Some(provider_request_body)
}
fn is_wire_preserving_deepseek_responses_hop(
fn is_wire_preserving_responses_hop(
source_api_format: &str,
provider_api_format: &str,
reasoning_replay_policy: crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy,
) -> bool {
if reasoning_replay_policy
!= crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
{
if !matches!(
reasoning_replay_policy,
crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy::DeepSeekOpaque
| crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy::XaiEncrypted
) {
return false;
}
let source_api_format = aether_ai_formats::normalize_api_format_alias(source_api_format);
@@ -1939,6 +1985,66 @@ mod tests {
assert_eq!(converted["tools"][0]["googleSearch"], json!({}));
}
#[test]
fn claude_client_web_search_tool_survives_conversion_to_gemini() {
let request = json!({
"model": "gemini-3-flash-preview",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "find the release notes"}],
"tools": [
{
"name": "WebSearch",
"description": "Search the web and use the results to inform responses",
"input_schema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
}
},
{
"name": "Read",
"description": "Read a file",
"input_schema": {
"type": "object",
"properties": {"file_path": {"type": "string"}},
"required": ["file_path"]
}
}
]
});
let converted = build_standard_request_body(
&request,
"claude:messages",
"gemini-3-flash-preview",
"google",
"gemini:generate_content",
"/v1/messages",
false,
None,
None,
)
.expect("claude messages should convert to gemini");
let tools = converted["tools"]
.as_array()
.expect("tools should be an array");
assert!(
tools.iter().all(|tool| tool.get("googleSearch").is_none()
&& tool.get("googleSearchRetrieval").is_none()),
"a client-declared WebSearch tool must not become server-side grounding: {tools:?}"
);
let declared: Vec<&str> = tools
.iter()
.filter_map(|tool| tool.get("functionDeclarations"))
.filter_map(Value::as_array)
.flatten()
.filter_map(|declaration| declaration.get("name").and_then(Value::as_str))
.collect();
assert_eq!(declared, vec!["WebSearch", "Read"], "{tools:?}");
}
#[test]
fn builds_claude_request_from_openai_chat_with_thinking_and_data_url_image() {
let request = json!({
@@ -2077,4 +2183,316 @@ mod tests {
);
assert_eq!(gemini["toolConfig"]["functionCallingConfig"]["mode"], "ANY");
}
#[test]
fn xai_keeps_client_search_functions_distinct_from_hosted_search() {
for name in ["web_search", "web_search_internal"] {
for hosted in [false, true] {
let mut tools = vec![json!({
"name": name,
"description": "Search internal documents",
"input_schema": {"type": "object", "properties": {"query": {"type": "string"}}}
})];
if hosted {
tools.push(json!({"type": "web_search_20260209", "name": "internet_search"}));
}
let request = json!({
"model": "source", "max_tokens": 64,
"messages": [{"role": "user", "content": "Search internal documents"}],
"tools": tools,
"tool_choice": {"type": "tool", "name": name}
});
let converted = build_standard_request_body(
&request,
"claude:messages",
"grok-4.6",
"xai",
"openai:responses",
"/v1/messages",
true,
None,
None,
)
.unwrap();
assert_eq!(
converted["tool_choice"],
json!({"type": "function", "name": name})
);
assert_eq!(
converted["tools"]
.as_array()
.unwrap()
.iter()
.any(|tool| tool["type"] == "web_search"),
hosted
);
}
}
let request = json!({
"model": "source", "max_tokens": 64,
"messages": [{"role": "user", "content": "Search the internet"}],
"tools": [{"type": "web_search_20260209", "name": "internet_search"}],
"tool_choice": {"type": "tool", "name": "internet_search"}
});
let converted = build_standard_request_body(
&request,
"claude:messages",
"grok-4.6",
"xai",
"openai:responses",
"/v1/messages",
true,
None,
None,
)
.unwrap();
assert_eq!(
converted["tool_choice"],
json!({
"type": "allowed_tools", "mode": "required", "tools": [{"type": "web_search"}]
})
);
}
#[test]
fn xai_preserves_function_choices_in_chat_and_responses_requests() {
for name in ["web_search", "web_search_internal"] {
for (client, request) in [
(
"openai:chat",
json!({
"messages": [{"role": "user", "content": "search"}],
"tools": [{"type": "function", "function": {"name": name, "parameters": {"type": "object"}}}],
"tool_choice": {"type": "function", "function": {"name": name}}
}),
),
(
"openai:responses",
json!({
"input": "search",
"tools": [{"type": "function", "name": name, "parameters": {"type": "object"}}],
"tool_choice": {"type": "function", "name": name}
}),
),
] {
let converted = build_standard_request_body(
&request,
client,
"grok-4.6",
"xai",
"openai:responses",
"/v1/responses",
true,
None,
None,
)
.unwrap();
assert_eq!(
converted["tool_choice"],
json!({"type": "function", "name": name})
);
assert_eq!(converted["tools"].as_array().unwrap().len(), 1);
}
}
}
#[test]
fn xai_image_allowed_tools_preserves_mode_and_restricts_available_tools() {
for mode in ["auto", "required"] {
for mixed in [false, true] {
let mut allowed = vec![json!({"type": "image_generation"})];
if mixed {
allowed.push(json!({"type": "function", "name": "lookup"}));
}
let request = json!({
"input": "Draw a cat",
"tools": [
{"type": "web_search"}, {"type": "image_generation"},
{"type": "function", "name": "lookup", "parameters": {"type": "object"}}
],
"tool_choice": {"type": "allowed_tools", "mode": mode, "tools": allowed}
});
let converted = build_standard_request_body(
&request,
"openai:responses",
"grok-4.6",
"xai",
"openai:responses",
"/v1/responses",
true,
None,
None,
)
.unwrap();
if mixed {
assert_eq!(
converted["tool_choice"],
json!({
"type": "allowed_tools", "mode": mode,
"tools": [{"type": "function", "name": "lookup"}]
})
);
assert_eq!(converted["tools"].as_array().unwrap().len(), 3);
} else {
assert_eq!(converted["tool_choice"], mode);
assert_eq!(converted["tools"], json!([{"type": "image_generation"}]));
}
}
}
}
#[test]
fn xai_responses_preserves_requested_encrypted_reasoning_and_replayed_input() {
let reasoning = json!({"type": "reasoning", "id": "550e8400-e29b-41d4-a716-446655440000", "summary": [], "encrypted_content": "opaque-xai-state"});
let request = json!({
"input": [reasoning.clone(), {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Previous answer"}]}, {"role": "user", "content": "Continue"}],
"include": ["reasoning.encrypted_content"], "store": false
});
let converted = build_standard_request_body(
&request,
"openai:responses",
"grok-4.6",
"xai",
"openai:responses",
"/v1/responses",
true,
None,
None,
)
.unwrap();
assert_eq!(converted["include"], request["include"]);
assert_eq!(converted["input"][0], reasoning);
assert_eq!(converted["store"], false);
}
#[test]
fn xai_standard_conversion_strips_unsupported_responses_fields() {
let request = json!({
"model": "source-model",
"messages": [{"role": "user", "content": "Hello xAI"}],
"max_tokens": 128,
"stop": ["END"],
"stream_options": {"include_usage": true},
"metadata": {"user_id": "claude-session"},
"web_search_options": {"search_context_size": "high"}
});
let converted = build_standard_request_body(
&request,
"openai:chat",
"grok-4.6",
"xai",
"openai:responses",
"/v1/chat/completions",
true,
None,
None,
)
.expect("chat should convert onto xAI Responses");
assert_eq!(converted["model"], "grok-4.6");
assert!(converted.get("stop").is_none());
assert!(converted.get("stream_options").is_none());
assert!(converted.get("previous_response_id").is_none());
assert!(converted.get("metadata").is_none());
assert!(converted.get("input").is_some() || converted.get("messages").is_none());
assert_eq!(converted["max_output_tokens"], 128);
assert_eq!(converted["tools"][0]["type"], "web_search");
}
#[test]
fn xai_standard_conversion_covers_claude_and_gemini_clients() {
let claude = json!({
"model": "claude-sonnet",
"max_tokens": 64,
"messages": [{"role": "user", "content": "Hello xAI"}],
"metadata": {
"user_id": "{\"device_id\":\"dev-1\",\"account_uuid\":\"acct-1\",\"session_id\":\"sess-1\"}"
},
"tools": [
{"type": "web_search_20250305", "name": "web_search"},
{
"name": "lookup",
"description": "Look something up",
"input_schema": {"type": "object", "properties": {}}
}
],
"tool_choice": {"type": "tool", "name": "web_search"}
});
let converted = build_standard_request_body(
&claude,
"claude:messages",
"grok-4.6",
"xai",
"openai:responses",
"/v1/messages",
true,
None,
None,
)
.expect("claude should convert onto xAI Responses");
assert_eq!(converted["model"], "grok-4.6");
assert!(converted.get("metadata").is_none());
assert!(converted.get("context_management").is_none());
assert!(converted
.get("include")
.and_then(Value::as_array)
.into_iter()
.flatten()
.any(|item| item == "reasoning.encrypted_content"));
assert!(converted["tools"]
.as_array()
.into_iter()
.flatten()
.any(|tool| tool["type"] == "web_search"));
assert_eq!(converted["tool_choice"]["type"], "allowed_tools");
assert!(converted.get("input").is_some());
let gemini = json!({
"model": "gemini-2.5-pro",
"contents": [{
"role": "user",
"parts": [{"text": "Hello xAI"}]
}],
"tools": [{"googleSearch": {}}]
});
let converted = build_standard_request_body(
&gemini,
"gemini:generate_content",
"grok-4.6",
"xai",
"openai:responses",
"/v1beta/models/gemini-2.5-pro:generateContent",
false,
None,
None,
)
.expect("gemini should convert onto xAI Responses");
assert_eq!(converted["model"], "grok-4.6");
assert_eq!(converted["tools"][0]["type"], "web_search");
assert!(converted.get("input").is_some());
let same_format = json!({
"model": "grok-4.6",
"input": "hello",
"previous_response_id": "resp_123",
"stop": ["END"],
"metadata": {"user_id": "claude-session"}
});
let converted = build_standard_request_body(
&same_format,
"openai:responses",
"grok-4.6",
"xai",
"openai:responses",
"/v1/responses",
true,
None,
None,
)
.expect("same-format xAI Responses should sanitize in place");
assert!(converted.get("previous_response_id").is_none());
assert!(converted.get("stop").is_none());
assert!(converted.get("metadata").is_none());
}
}
@@ -65,7 +65,7 @@ fn chat_compatible_body_for_openai_chat_endpoint(body_json: &Value) -> Option<Co
Some(Cow::Borrowed(body_json))
}
fn chat_compatible_body_for_standard_source<'a>(
pub(crate) fn chat_compatible_body_for_standard_source<'a>(
body_json: &'a Value,
client_api_format: &str,
history_scope: Option<&str>,
@@ -167,6 +167,40 @@ pub fn build_cross_format_openai_chat_request_body(
)
}
/// Provider-aware entry point for gateway Chat planners. Keep private schema
/// conversion policy in the format crate while retaining legacy behavior elsewhere.
pub fn build_cross_format_openai_chat_request_body_with_provider_context(
body_json: &Value,
mapped_model: &str,
provider_type: &str,
provider_api_format: &str,
upstream_is_stream: bool,
enable_model_directives: bool,
history_scope: Option<&str>,
) -> Option<Value> {
if super::standard_matrix::preserves_gemini_tool_schemas(provider_type, provider_api_format) {
return super::standard_matrix::build_standard_request_body_with_model_directives(
body_json,
"openai:chat",
mapped_model,
provider_type,
provider_api_format,
"",
upstream_is_stream,
None,
history_scope,
enable_model_directives,
);
}
build_cross_format_openai_chat_request_body_with_model_directives(
body_json,
mapped_model,
provider_api_format,
upstream_is_stream,
enable_model_directives,
)
}
pub fn build_cross_format_openai_chat_request_body_with_model_directives(
body_json: &Value,
mapped_model: &str,
@@ -342,6 +376,44 @@ pub fn build_cross_format_openai_responses_request_body_with_model_directives(
)
}
/// Provider-aware Responses entry point; preserve history scoping and defer
/// private tool schema lowering without exposing provider policy to the gateway.
#[allow(clippy::too_many_arguments)]
pub fn build_cross_format_openai_responses_request_body_with_provider_context(
body_json: &Value,
mapped_model: &str,
client_api_format: &str,
provider_type: &str,
provider_api_format: &str,
upstream_is_stream: bool,
enable_model_directives: bool,
history_scope: Option<&str>,
) -> Option<Value> {
if super::standard_matrix::preserves_gemini_tool_schemas(provider_type, provider_api_format) {
return super::standard_matrix::build_standard_request_body_with_model_directives(
body_json,
client_api_format,
mapped_model,
provider_type,
provider_api_format,
"",
upstream_is_stream,
None,
history_scope,
enable_model_directives,
);
}
build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope(
body_json,
mapped_model,
client_api_format,
provider_api_format,
upstream_is_stream,
enable_model_directives,
history_scope,
)
}
pub fn build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope(
body_json: &Value,
mapped_model: &str,
@@ -433,6 +505,145 @@ mod tests {
};
use serde_json::{json, Value};
#[test]
fn provider_context_builders_preserve_private_schemas_and_legacy_routes() {
use crate::api::{
build_cross_format_openai_chat_request_body_with_provider_context as chat,
build_cross_format_openai_responses_request_body_with_provider_context as responses,
};
let schema = json!({"type":"object", "properties":{"mode":{"const":"fast"}}});
let chat_input = json!({"model":"client", "messages":[{"role":"user","content":"hi"}],
"tools":[{"type":"function","function":{"name":"probe","parameters":schema}}]});
let responses_input = json!({"model":"client", "input":"hi",
"tools":[{"type":"function","name":"probe","parameters":schema}]});
for provider in ["antigravity", " AnTiGrAvItY ", "gemini", "openai"] {
for target in [
"gemini:generate_content",
"claude:messages",
"openai:responses",
] {
for stream in [false, true] {
for directives in [false, true] {
for input in [&chat_input, &responses_input] {
let actual = chat(
input,
"claude-test",
provider,
target,
stream,
directives,
Some("seam-test"),
);
let expected =
if super::super::standard_matrix::preserves_gemini_tool_schemas(
provider, target,
) {
super::super::standard_matrix::build_standard_request_body_with_model_directives(
input, "openai:chat", "claude-test", provider, target, "", stream, None, Some("seam-test"), directives)
} else {
super::build_cross_format_openai_chat_request_body_with_model_directives(
input, "claude-test", target, stream, directives)
};
assert!(actual.is_some(), "chat {provider} {target}");
assert_eq!(actual, expected);
if target == "gemini:generate_content" {
assert_eq!(
actual.unwrap()["tools"][0]["functionDeclarations"][0]
["parameters"]
== schema,
provider.trim().eq_ignore_ascii_case("antigravity")
);
}
}
let actual = responses(
&responses_input,
"claude-test",
"openai:responses",
provider,
target,
stream,
directives,
Some("seam-test"),
);
let expected =
if super::super::standard_matrix::preserves_gemini_tool_schemas(
provider, target,
) {
super::super::standard_matrix::build_standard_request_body_with_model_directives(
&responses_input, "openai:responses", "claude-test", provider, target, "", stream, None, Some("seam-test"), directives)
} else {
super::build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope(
&responses_input, "claude-test", "openai:responses", target, stream, directives, Some("seam-test"))
};
// Same-format Responses uses the local builder, not this cross-format API.
assert_eq!(
actual.is_some(),
target != "openai:responses",
"responses {provider} {target}"
);
assert_eq!(actual, expected);
}
}
}
}
}
#[test]
fn provider_context_builders_keep_scoped_responses_history() {
use crate::api::{
build_cross_format_openai_chat_request_body_with_provider_context as chat,
build_cross_format_openai_responses_request_body_with_provider_context as responses,
record_converted_response_history,
};
let response_id = "resp_provider_context_seam_history";
let scope = "provider-context-seam-history";
record_converted_response_history(&json!({
"needs_conversion":true, "client_api_format":"openai:responses",
"provider_api_format":"openai:chat", "api_key_id":scope,
"original_request_body":{"model":"client", "input":"first"}
}), &json!({"id":response_id, "status":"completed", "output":[{
"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"remembered"}]
}]})).expect("seed scoped history");
let input = json!({"model":"client", "previous_response_id":response_id, "input":"second"});
for use_chat in [false, true] {
let build = |history_scope| {
if use_chat {
chat(
&input,
"claude-test",
"antigravity",
"gemini:generate_content",
true,
false,
history_scope,
)
} else {
responses(
&input,
"claude-test",
"openai:responses",
"antigravity",
"gemini:generate_content",
true,
false,
history_scope,
)
}
};
if use_chat {
// The legacy Chat alternate-shape path does not hydrate scoped
// Responses history. Preserve that behavior during this refactor.
assert!(build(Some(scope)).is_none());
continue;
}
let output = build(Some(scope)).expect("expand scoped history");
assert_eq!(output["contents"][0]["parts"][0]["text"], "first");
assert_eq!(output["contents"][1]["parts"][0]["text"], "remembered");
assert_eq!(output["contents"][2]["parts"][0]["text"], "second");
assert!(build(Some("different-seam-key")).is_none());
}
}
fn object_keys(value: &Value) -> Vec<&str> {
value
.as_object()
@@ -875,6 +875,104 @@ mod tests {
format!("event: {event}\n").into_bytes()
}
/// Gemini runs `googleSearch` inside Google, so a grounded streaming answer
/// carries its evidence as `groundingMetadata` on the final chunk and never
/// as a tool call. Each client family has to receive it in its own citation
/// shape, or the answer streams out unverifiable.
#[test]
fn streams_gemini_grounding_to_every_client_as_native_citations() {
let text = "今天是 2026 年";
let first = json!({
"responseId": "resp_grounded",
"modelVersion": "gemini-3.8-flash",
"candidates": [{
"index": 0,
"content": {"role": "model", "parts": [{"text": text}]}
}]
});
let last = json!({
"responseId": "resp_grounded",
"modelVersion": "gemini-3.8-flash",
"candidates": [{
"index": 0,
"finishReason": "STOP",
"content": {"role": "model", "parts": [{"text": text}]},
"groundingMetadata": {
"webSearchQueries": ["current UTC date"],
"groundingChunks": [{
"web": {"uri": "https://time.gov/", "title": "time.gov"}
}],
"groundingSupports": [{
"segment": {"startIndex": 0, "endIndex": 15},
"groundingChunkIndices": [0]
}]
}
}]
});
for (client_api_format, marker) in [
("openai:chat", "\"annotations\":[{\"type\":\"url_citation\""),
(
"openai:responses",
"event: response.output_text.annotation.added\n",
),
("claude:messages", "\"type\":\"citations_delta\""),
] {
let context = report_context("gemini:generate_content", client_api_format);
let mut matrix = StreamingStandardFormatMatrix::default();
let mut output = matrix
.transform_line(&context, data_line(first.clone()))
.expect("text chunk");
output.extend(
matrix
.transform_line(&context, data_line(last.clone()))
.expect("grounded chunk"),
);
output.extend(matrix.finish(&context).expect("finish"));
let sse = String::from_utf8(output).expect("valid SSE");
assert!(
sse.contains(marker),
"{client_api_format} missing citations: {sse}"
);
assert!(
sse.contains("https://time.gov/"),
"{client_api_format} missing source url: {sse}"
);
}
}
/// The citation frame is emitted once the answer is whole, so a provider
/// that closes the stream without a `finishReason` must still deliver it.
#[test]
fn streams_gemini_grounding_even_when_the_provider_never_sends_a_finish_reason() {
let context = report_context("gemini:generate_content", "openai:chat");
let mut matrix = StreamingStandardFormatMatrix::default();
let mut output = matrix
.transform_line(
&context,
data_line(json!({
"responseId": "resp_grounded",
"modelVersion": "gemini-3.8-flash",
"candidates": [{
"index": 0,
"content": {"role": "model", "parts": [{"text": "grounded"}]},
"groundingMetadata": {
"groundingChunks": [{"web": {"uri": "https://time.gov/"}}]
}
}]
})),
)
.expect("grounded chunk");
output.extend(matrix.finish(&context).expect("finish"));
let sse = String::from_utf8(output).expect("valid SSE");
assert!(
sse.contains("url_citation") && sse.contains("https://time.gov/"),
"{sse}"
);
}
#[test]
fn terminal_observer_marks_malformed_gemini_function_call_as_failure() {
let context = report_context("gemini:generate_content", "openai:responses");
@@ -951,7 +1049,11 @@ mod tests {
let sse = String::from_utf8(output).expect("reasoning SSE should be utf8");
assert!(
sse.contains("event: response.reasoning_summary_text.delta\n"),
sse.contains("event: response.reasoning_text.delta\n"),
"{sse}"
);
assert!(
!sse.contains("event: response.reasoning_summary_text.delta\n"),
"{sse}"
);
assert!(sse.contains("\"delta\":\"checking\""), "{sse}");
@@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
@@ -226,7 +227,7 @@ enum AiSurfaceStreamRewriteState {
}
pub struct AiSurfaceStreamRewriter<'a> {
report_context: &'a Value,
report_context: Cow<'a, Value>,
buffered: Vec<u8>,
state: AiSurfaceStreamRewriteState,
}
@@ -271,30 +272,39 @@ pub fn maybe_build_ai_surface_stream_rewriter<'a>(
};
Some(AiSurfaceStreamRewriter {
report_context,
report_context: Cow::Borrowed(report_context),
buffered: Vec::new(),
state,
})
}
impl AiSurfaceStreamRewriter<'_> {
/// Move parser state across task boundaries without replaying captured bytes.
pub fn into_owned(self) -> AiSurfaceStreamRewriter<'static> {
AiSurfaceStreamRewriter {
report_context: Cow::Owned(self.report_context.into_owned()),
buffered: self.buffered,
state: self.state,
}
}
pub fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match &mut self.state {
AiSurfaceStreamRewriteState::OpenAiImage(state) => {
state.push_chunk(self.report_context, chunk)
state.push_chunk(self.report_context.as_ref(), chunk)
}
AiSurfaceStreamRewriteState::OpenAiImageToOpenAiChat(state) => {
state.push_chunk(self.report_context, chunk)
state.push_chunk(self.report_context.as_ref(), chunk)
}
AiSurfaceStreamRewriteState::ClaudeReadToolSanitize(state) => {
state.push_chunk(self.report_context, chunk)
state.push_chunk(self.report_context.as_ref(), chunk)
}
AiSurfaceStreamRewriteState::KiroToClaudeCli(state) => {
state.push_chunk(self.report_context, chunk)
state.push_chunk(self.report_context.as_ref(), chunk)
}
AiSurfaceStreamRewriteState::KiroToClaudeCliThenStandard { kiro, standard } => {
let claude_bytes = kiro.push_chunk(self.report_context, chunk)?;
transform_standard_bytes(standard, self.report_context, claude_bytes)
let claude_bytes = kiro.push_chunk(self.report_context.as_ref(), chunk)?;
transform_standard_bytes(standard, self.report_context.as_ref(), claude_bytes)
}
AiSurfaceStreamRewriteState::EnvelopeUnwrap
| AiSurfaceStreamRewriteState::ModelDirectiveDisplay
@@ -313,23 +323,25 @@ impl AiSurfaceStreamRewriter<'_> {
pub fn finish(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match &mut self.state {
AiSurfaceStreamRewriteState::OpenAiImage(state) => state.finish(self.report_context),
AiSurfaceStreamRewriteState::OpenAiImage(state) => {
state.finish(self.report_context.as_ref())
}
AiSurfaceStreamRewriteState::OpenAiImageToOpenAiChat(state) => {
state.finish(self.report_context)
state.finish(self.report_context.as_ref())
}
AiSurfaceStreamRewriteState::ClaudeReadToolSanitize(state) => {
state.finish(self.report_context)
state.finish(self.report_context.as_ref())
}
AiSurfaceStreamRewriteState::KiroToClaudeCli(state) => {
state.finish(self.report_context)
state.finish(self.report_context.as_ref())
}
AiSurfaceStreamRewriteState::KiroToClaudeCliThenStandard { kiro, standard } => {
let mut output = transform_standard_bytes(
standard,
self.report_context,
kiro.finish(self.report_context)?,
self.report_context.as_ref(),
kiro.finish(self.report_context.as_ref())?,
)?;
output.extend(standard.finish(self.report_context)?);
output.extend(standard.finish(self.report_context.as_ref())?);
Ok(output)
}
AiSurfaceStreamRewriteState::EnvelopeUnwrap
@@ -338,14 +350,14 @@ impl AiSurfaceStreamRewriter<'_> {
| AiSurfaceStreamRewriteState::Standard(_) => {
if self.buffered.is_empty() {
if let AiSurfaceStreamRewriteState::Standard(state) = &mut self.state {
return state.finish(self.report_context);
return state.finish(self.report_context.as_ref());
}
return Ok(Vec::new());
}
let line = std::mem::take(&mut self.buffered);
let mut output = self.transform_line(line)?;
if let AiSurfaceStreamRewriteState::Standard(state) = &mut self.state {
output.extend(state.finish(self.report_context)?);
output.extend(state.finish(self.report_context.as_ref())?);
}
Ok(output)
}
@@ -365,18 +377,19 @@ impl AiSurfaceStreamRewriter<'_> {
fn transform_line(&mut self, line: Vec<u8>) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match &mut self.state {
AiSurfaceStreamRewriteState::EnvelopeUnwrap => {
let output = transform_provider_private_stream_line(self.report_context, line)
.map_err(AiSurfaceFinalizeError::from)?;
rewrite_model_directive_stream_line(self.report_context, output)
let output =
transform_provider_private_stream_line(self.report_context.as_ref(), line)
.map_err(AiSurfaceFinalizeError::from)?;
rewrite_model_directive_stream_line(self.report_context.as_ref(), output)
}
AiSurfaceStreamRewriteState::ModelDirectiveDisplay => {
rewrite_model_directive_stream_line(self.report_context, line)
rewrite_model_directive_stream_line(self.report_context.as_ref(), line)
}
AiSurfaceStreamRewriteState::OpenAiResponsesCompat => {
rewrite_openai_responses_compat_stream_line(self.report_context, line)
rewrite_openai_responses_compat_stream_line(self.report_context.as_ref(), line)
}
AiSurfaceStreamRewriteState::Standard(state) => {
transform_standard_line(state, self.report_context, line)
transform_standard_line(state, self.report_context.as_ref(), line)
}
AiSurfaceStreamRewriteState::OpenAiImage(_)
| AiSurfaceStreamRewriteState::OpenAiImageToOpenAiChat(_)
@@ -892,7 +905,7 @@ fn is_standard_cli_client_api_format(api_format: &str) -> bool {
#[cfg(test)]
mod tests {
use serde_json::json;
use serde_json::{json, Value};
use super::{
maybe_build_ai_surface_stream_rewriter, resolve_finalize_stream_rewrite_mode,
@@ -1067,6 +1080,50 @@ data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\",\"object\
assert!(!output.contains("\"model\":\"gpt-5.5\""));
}
#[test]
fn owned_handoff_preserves_partial_utf8_and_conversion_state() {
for client in ["openai:responses", "openai:chat"] {
let text = "界".repeat(12_000);
let delta = format!(
"data: {}\n\n",
json!({
"type":"response.output_text.delta", "response_id":"resp_handoff",
"item_id":"msg_handoff", "output_index":0, "content_index":0, "delta":text,
})
);
let split = delta.find('界').unwrap() + 17_002;
assert!(!delta.is_char_boundary(split));
let (mut owned, mut output) = {
let context = json!({"provider_api_format":"openai:responses",
"client_api_format":client, "needs_conversion":client == "openai:chat"});
let mut parser = maybe_build_ai_surface_stream_rewriter(Some(&context)).unwrap();
let output = parser.push_chunk(&delta.as_bytes()[..split]).unwrap();
(parser.into_owned(), output)
};
output.extend(owned.push_chunk(&delta.as_bytes()[split..]).unwrap());
output.extend(owned.finish().unwrap());
let output = String::from_utf8(output).unwrap();
let events: Vec<Value> = output
.lines()
.filter_map(|l| l.strip_prefix("data: "))
.filter(|p| *p != "[DONE]")
.map(|p| serde_json::from_str(p).unwrap())
.collect();
let recovered: String = events
.iter()
.filter_map(|e| {
if client == "openai:responses" {
e["delta"].as_str()
} else {
e.pointer("/choices/0/delta/content")
.and_then(Value::as_str)
}
})
.collect();
assert_eq!(recovered, text);
}
}
#[test]
fn standard_rewriter_converts_openai_responses_reasoning_delta_to_chat() {
let report_context = json!({
@@ -9,7 +9,8 @@ use aether_ai_formats::formats::conversion::response::{
};
use aether_ai_formats::formats::openai::responses::response::ensure_modern_openai_responses_response_fields;
use aether_ai_formats::formats::openai::responses::{
openai_responses_message_item_id, openai_responses_synthetic_reasoning_item_id,
openai_responses_message_item_id, openai_responses_reasoning_text_parts,
openai_responses_synthetic_reasoning_item_id,
};
use aether_ai_formats::formats::registry::{convert_response, FormatContext, FormatError};
use aether_ai_formats::{
@@ -26,6 +27,7 @@ use serde_json::{json, Map, Value};
use super::{decode_sync_report_body_base64, AiSurfaceFinalizeError};
use crate::formats::claude::messages::stream::ClaudeProviderState;
use crate::formats::gemini::generate_content::stream::GeminiProviderState;
use crate::formats::openai::chat::response::openai_chat_reasoning_texts;
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::sanitize_claude_read_tool_inputs;
@@ -113,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,
@@ -126,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,
@@ -1472,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.
@@ -1488,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 {
@@ -1827,6 +1897,7 @@ fn apply_report_context_model_fallback(model: &mut String, report_context: &Valu
struct OpenAIChatChoiceState {
role: Option<String>,
content: String,
reasoning: String,
finish_reason: Option<String>,
tool_calls: BTreeMap<usize, OpenAIChatToolCallState>,
}
@@ -2195,6 +2266,9 @@ pub fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option<Value>
if let Some(content) = delta.get("content").and_then(Value::as_str) {
state.content.push_str(content);
}
for (_, piece) in openai_chat_reasoning_texts(delta) {
state.reasoning.push_str(&piece);
}
if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) {
for tool_call in tool_calls {
let Some(tool_call_object) = tool_call.as_object() else {
@@ -2254,6 +2328,14 @@ pub fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option<Value>
"role".to_string(),
Value::String(state.role.unwrap_or_else(|| "assistant".to_string())),
);
// Reassemble under the spelling this crate emits for Chat clients; the
// provider's own spelling was already normalized away by the parser.
if !state.reasoning.is_empty() {
message.insert(
"reasoning_content".to_string(),
Value::String(state.reasoning),
);
}
if state.tool_calls.is_empty() {
message.insert("content".to_string(), Value::String(state.content));
} else {
@@ -2457,7 +2539,7 @@ fn aggregate_openai_responses_stream_sync_response_from_validated_terminal(
reasoning_states
.entry(output_index)
.or_default()
.summary_text
.reasoning_text
.push_str(delta);
}
"response.reasoning_text.done" | "response.reasoning_summary_text.done" => {
@@ -2785,7 +2867,7 @@ struct OpenAIResponsesSyncMessageState {
#[derive(Default)]
struct OpenAIResponsesSyncReasoningState {
item: Map<String, Value>,
summary_text: String,
reasoning_text: String,
}
#[derive(Default)]
@@ -3099,8 +3181,8 @@ fn merge_openai_responses_reasoning_text(
if text.is_empty() {
return;
}
if state.summary_text.is_empty() || text.len() >= state.summary_text.len() {
state.summary_text = text.to_string();
if state.reasoning_text.is_empty() || text.len() >= state.reasoning_text.len() {
state.reasoning_text = text.to_string();
}
}
@@ -3117,19 +3199,27 @@ fn merge_openai_responses_tool_arguments(
}
fn extract_openai_responses_reasoning_text(item: &Map<String, Value>) -> Option<String> {
item.get("summary")
.and_then(Value::as_array)
extract_openai_responses_reasoning_parts(item.get("content"), "reasoning_text")
.or_else(|| extract_openai_responses_reasoning_parts(item.get("summary"), "summary_text"))
}
fn extract_openai_responses_reasoning_parts(
raw: Option<&Value>,
expected_type: &str,
) -> Option<String> {
raw.and_then(Value::as_array)
.into_iter()
.flatten()
.find_map(|part| {
let part = part.as_object()?;
(part.get("type").and_then(Value::as_str) == Some("summary_text")).then(|| {
(part.get("type").and_then(Value::as_str) == Some(expected_type)).then(|| {
part.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
})
})
.filter(|text| !text.is_empty())
}
fn merge_openai_responses_message_item(
@@ -3275,18 +3365,28 @@ fn materialize_openai_responses_reasoning_item(
});
item.entry("status".to_string())
.or_insert_with(|| Value::String("completed".to_string()));
if !state.summary_text.is_empty() {
item.insert(
"summary".to_string(),
Value::Array(vec![json!({
"type": "summary_text",
"text": state.summary_text,
})]),
);
if !state.reasoning_text.is_empty()
&& reasoning_item_field_missing_or_empty(item.get("content"))
{
let content = openai_responses_reasoning_text_parts([&state.reasoning_text]);
item.insert("content".to_string(), content);
}
// Raw chain-of-thought lives on `content` only; never mirror it onto
// `summary`, or clients that render both channels show it twice.
item.entry("summary".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
Value::Object(item)
}
fn reasoning_item_field_missing_or_empty(value: Option<&Value>) -> bool {
match value {
None | Some(Value::Null) => true,
Some(Value::Array(parts)) => parts.is_empty(),
Some(Value::String(text)) => text.trim().is_empty(),
_ => false,
}
}
fn materialize_openai_responses_tool_item(
output_index: usize,
state: OpenAIResponsesSyncToolState,
@@ -3631,6 +3731,11 @@ fn try_aggregate_gemini_stream_sync_response(
CanonicalStreamEvent::TextDelta(text) => {
append_gemini_text_part(&mut parts, text, false);
}
// This rebuilds a raw Gemini body, and every non-`content`
// candidate key — `groundingMetadata` included — is already
// copied across above. Projecting it into citations is the
// job of whoever converts that body onward.
CanonicalStreamEvent::Citations(_) => {}
CanonicalStreamEvent::ReasoningDelta(text) => {
append_gemini_text_part(&mut parts, text, true);
}
@@ -4139,6 +4244,92 @@ mod tests {
);
}
#[test]
fn aggregates_openai_chat_stream_reasoning_into_sync_body() {
// The aggregator used to keep only `content` and `tool_calls`, so a
// stream downgraded to a sync response lost the reasoning entirely —
// for OpenRouter's `reasoning`/`reasoning_details` and for the
// DeepSeek-style `reasoning_content` alike.
let body = concat!(
"data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"Let me\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"Let me\",\"index\":0}]},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" think.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" think.\",\"index\":0}]},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Done.\",\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n",
);
let result = aggregate_openai_chat_stream_sync_response(body.as_bytes())
.expect("openrouter chat stream should aggregate into a sync body");
let message = &result["choices"][0]["message"];
assert_eq!(message["content"], "Done.");
// `reasoning` and `reasoning_details` repeat one another, so the
// reassembled text must not double up.
assert_eq!(message["reasoning_content"], "Let me think.");
assert_eq!(result["choices"][0]["finish_reason"], "stop");
}
#[test]
fn aggregates_deepseek_reasoning_content_into_sync_body() {
let body = concat!(
"data: {\"id\":\"chatcmpl-deepseek\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-reasoner\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"reasoning_content\":\"Let me\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-deepseek\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-reasoner\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" think.\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-deepseek\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-reasoner\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"42\"},\"finish_reason\":\"stop\"}]}\n\n",
);
let result = aggregate_openai_chat_stream_sync_response(body.as_bytes())
.expect("deepseek chat stream should aggregate into a sync body");
let message = &result["choices"][0]["message"];
assert_eq!(message["content"], "42");
assert_eq!(message["reasoning_content"], "Let me think.");
assert_eq!(result["choices"][0]["finish_reason"], "stop");
}
#[test]
fn aggregated_chat_stream_without_reasoning_adds_no_reasoning_key() {
let body = "data: {\"id\":\"chatcmpl-openai\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n";
let result = aggregate_openai_chat_stream_sync_response(body.as_bytes())
.expect("plain chat stream should aggregate into a sync body");
let message = &result["choices"][0]["message"];
assert_eq!(message["content"], "hi");
assert!(
message.get("reasoning_content").is_none(),
"a stream with no reasoning must not gain a reasoning key: {message}"
);
}
#[test]
fn aggregated_openai_chat_reasoning_reaches_every_client_format() {
let body = concat!(
"data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"Thinking.\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Done.\",\"role\":\"assistant\"},\"finish_reason\":\"stop\"}]}\n\n",
);
let aggregated = aggregate_openai_chat_stream_sync_response(body.as_bytes())
.expect("openrouter chat stream should aggregate into a sync body");
let report_context = json!({});
for (client_api_format, marker) in [
("openai:responses", "\"type\":\"reasoning\""),
("claude:messages", "\"type\":\"thinking\""),
("gemini:generate_content", "\"thought\":true"),
] {
let converted = convert_standard_chat_response(
&aggregated,
"openai:chat",
client_api_format,
&report_context,
)
.unwrap_or_else(|| panic!("{client_api_format} should convert"));
let encoded = serde_json::to_string(&converted).expect("converted body should encode");
assert!(
encoded.contains(marker),
"{client_api_format} dropped the reasoning block: {encoded}"
);
}
}
#[test]
fn aggregates_openai_chat_stream_tool_usage_and_finish_into_sync_body() {
let body = concat!(
@@ -5606,7 +5797,9 @@ mod tests {
.expect("modern response.done stream should aggregate");
assert_eq!(result["output"][0]["type"], "reasoning");
assert_eq!(result["output"][0]["summary"][0]["text"], "Need care");
assert_eq!(result["output"][0]["summary"], json!([]));
assert_eq!(result["output"][0]["content"][0]["type"], "reasoning_text");
assert_eq!(result["output"][0]["content"][0]["text"], "Need care");
assert!(result["output"].as_array().is_some());
assert_eq!(result["output_text"], "");
assert!(result["completed_at"].as_i64().is_some());
@@ -5632,7 +5825,7 @@ mod tests {
.as_object()
.expect("reasoning item should be an object")
.clone(),
summary_text: "must not replace provider-owned state".to_string(),
reasoning_text: "must not replace provider-owned state".to_string(),
};
let materialized = materialize_openai_responses_reasoning_item("resp_opaque_123", state);
@@ -5673,13 +5866,13 @@ mod tests {
}
#[test]
fn synthesizes_wire_compatible_id_for_local_reasoning_summary() {
fn synthesizes_wire_compatible_id_for_local_reasoning_text() {
let state = OpenAIResponsesSyncReasoningState {
item: json!({"type": "reasoning"})
.as_object()
.expect("reasoning item should be an object")
.clone(),
summary_text: "Need care".to_string(),
reasoning_text: "Need care".to_string(),
};
let materialized = materialize_openai_responses_reasoning_item("resp_summary_123", state);
@@ -5688,7 +5881,9 @@ mod tests {
materialized["id"],
openai_responses_synthetic_reasoning_item_id("resp_summary_123", 0)
);
assert_eq!(materialized["summary"][0]["text"], "Need care");
assert_eq!(materialized["summary"], json!([]));
assert_eq!(materialized["content"][0]["type"], "reasoning_text");
assert_eq!(materialized["content"][0]["text"], "Need care");
}
#[test]
@@ -7016,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!({
+9 -1
View File
@@ -1,11 +1,16 @@
extern crate self as aether_ai_formats;
pub mod api;
pub mod codex_profile;
pub mod contracts;
pub mod formats;
pub mod protocol;
pub mod provider_compat;
pub use codex_profile::{
codex_client_originator, codex_client_profile, codex_client_user_agent, codex_client_version,
set_codex_cli_version, set_codex_client_profile, CodexClientKind, CodexClientProfile,
};
pub use contracts::{ApiOperation, ClientSurface};
pub use formats::context::{
@@ -50,12 +55,15 @@ pub use formats::openai::responses::codex::{
codex_responses_lite_tool_is_client_executed, effective_codex_model_cards,
parse_codex_auth_identity, project_codex_catalog_model_card,
resolve_codex_responses_model_capabilities, CodexAuthIdentity, CodexResponsesModelCapabilities,
CODEX_CLIENT_ORIGINATOR, CODEX_CLIENT_USER_AGENT, CODEX_CLIENT_VERSION,
CODEX_MODEL_CATALOG_METADATA_FIELD, CODEX_RESPONSES_LITE_HEADER,
};
pub use formats::openai::responses::request::{
validate_openai_responses_request_contract, OpenAiResponsesRequestContractViolation,
};
pub use formats::openai::responses::xai::{
apply_xai_upstream_payload_edits, apply_xai_upstream_payload_edits_with_client,
xai_model_supports_reasoning_effort, xai_supports_native_image_generation,
};
pub use formats::openai::responses::{
normalize_openai_responses_message_item_ids, openai_responses_message_item_id,
openai_responses_request_operation, openai_responses_synthetic_reasoning_item_id,
@@ -16,6 +16,7 @@ pub use crate::protocol::stream::{CanonicalStreamEvent, CanonicalStreamFrame};
pub(crate) const OPENAI_RESPONSES_EXTENSION_NAMESPACE: &str = "openai_responses";
pub(crate) const OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE: &str = "openai_cli";
pub(crate) const CLAUDE_EXTENSION_NAMESPACE: &str = "claude";
const AETHER_EXTENSION_NAMESPACE: &str = "aether";
const CLAUDE_MESSAGES_REQUEST_SOURCE_MARKER: &str = "claude_messages_request";
const CLAUDE_SYSTEM_SOURCE_MARKER: &str = "claude_system";
@@ -2860,9 +2861,9 @@ fn openai_responses_reasoning_block_from_item(
}
fn openai_responses_reasoning_text(item_object: &Map<String, Value>) -> String {
let mut parts = openai_responses_reasoning_text_parts(item_object.get("summary"));
let mut parts = openai_responses_reasoning_text_parts(item_object.get("content"));
if parts.is_empty() {
parts = openai_responses_reasoning_text_parts(item_object.get("content"));
parts = openai_responses_reasoning_text_parts(item_object.get("summary"));
}
parts.join("\n")
}
@@ -2961,38 +2962,47 @@ pub(crate) fn openai_responses_output_to_canonical(
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
if let Some(summary_items) = item_object.get("summary").and_then(Value::as_array) {
for summary in summary_items {
let Some(summary_object) = summary.as_object() else {
continue;
};
let text = summary_object
.get("text")
.and_then(Value::as_str)
.unwrap_or_default();
if text.trim().is_empty() {
continue;
}
let mut extensions = openai_responses_extensions(
item_object,
&["type", "id", "status", "summary", "encrypted_content"],
);
canonical_extension_object_mut(&mut extensions, "openai")
.insert("omit_reasoning_parts".to_string(), Value::Bool(true));
let extensions = openai_thinking_extensions(extensions);
blocks.push(CanonicalContentBlock::Thinking {
text: text.to_string(),
signature: None,
encrypted_content: encrypted_content.clone(),
extensions,
});
emitted = true;
let mut texts = openai_responses_reasoning_text_parts(item_object.get("content"));
if texts.is_empty() {
texts = openai_responses_reasoning_text_parts(item_object.get("summary"));
}
for text in texts {
if text.trim().is_empty() {
continue;
}
let mut extensions = openai_responses_extensions(
item_object,
&[
"type",
"id",
"status",
"summary",
"content",
"encrypted_content",
],
);
canonical_extension_object_mut(&mut extensions, "openai")
.insert("omit_reasoning_parts".to_string(), Value::Bool(true));
let extensions = openai_thinking_extensions(extensions);
blocks.push(CanonicalContentBlock::Thinking {
text,
signature: None,
encrypted_content: encrypted_content.clone(),
extensions,
});
emitted = true;
}
if !emitted && encrypted_content.is_some() {
let mut extensions = openai_responses_extensions(
item_object,
&["type", "id", "status", "summary", "encrypted_content"],
&[
"type",
"id",
"status",
"summary",
"content",
"encrypted_content",
],
);
canonical_extension_object_mut(&mut extensions, "openai")
.insert("omit_reasoning_parts".to_string(), Value::Bool(true));
@@ -4925,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()
@@ -4943,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>,
@@ -5116,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")
@@ -5870,7 +5957,11 @@ pub(crate) fn canonical_block_to_claude(
let mut out = Map::new();
out.insert("type".to_string(), Value::String("text".to_string()));
out.insert("text".to_string(), Value::String(text.clone()));
out.extend(namespace_extension_object(extensions, "claude", &out));
out.extend(namespace_extension_object(
extensions,
CLAUDE_EXTENSION_NAMESPACE,
&out,
));
Some(Some(Value::Object(out)))
}
CanonicalContentBlock::Thinking {
@@ -5890,7 +5981,11 @@ pub(crate) fn canonical_block_to_claude(
Value::String("redacted_thinking".to_string()),
);
out.insert("data".to_string(), Value::String(data.clone()));
out.extend(namespace_extension_object(extensions, "claude", &out));
out.extend(namespace_extension_object(
extensions,
CLAUDE_EXTENSION_NAMESPACE,
&out,
));
return Some(Some(Value::Object(out)));
}
if !matches!(role, CanonicalRole::Assistant) {
@@ -5911,7 +6006,11 @@ pub(crate) fn canonical_block_to_claude(
if let Some(signature) = signature.as_ref().filter(|value| !value.is_empty()) {
out.insert("signature".to_string(), Value::String(signature.clone()));
}
out.extend(namespace_extension_object(extensions, "claude", &out));
out.extend(namespace_extension_object(
extensions,
CLAUDE_EXTENSION_NAMESPACE,
&out,
));
Some(Some(Value::Object(out)))
}
CanonicalContentBlock::Image {
@@ -5936,7 +6035,11 @@ pub(crate) fn canonical_block_to_claude(
"source".to_string(),
claude_source_value(media_type.as_deref(), data.as_deref(), url.as_deref())?,
);
out.extend(namespace_extension_object(extensions, "claude", &out));
out.extend(namespace_extension_object(
extensions,
CLAUDE_EXTENSION_NAMESPACE,
&out,
));
Some(Some(Value::Object(out)))
}
CanonicalContentBlock::File {
@@ -5959,7 +6062,11 @@ pub(crate) fn canonical_block_to_claude(
"source".to_string(),
claude_source_value(media_type.as_deref(), data.as_deref(), file_url.as_deref())?,
);
out.extend(namespace_extension_object(extensions, "claude", &out));
out.extend(namespace_extension_object(
extensions,
CLAUDE_EXTENSION_NAMESPACE,
&out,
));
Some(Some(Value::Object(out)))
}
CanonicalContentBlock::Audio {
@@ -5979,7 +6086,11 @@ pub(crate) fn canonical_block_to_claude(
None,
)?,
);
out.extend(namespace_extension_object(extensions, "claude", &out));
out.extend(namespace_extension_object(
extensions,
CLAUDE_EXTENSION_NAMESPACE,
&out,
));
Some(Some(Value::Object(out)))
}
CanonicalContentBlock::ToolUse {
@@ -5997,7 +6108,11 @@ pub(crate) fn canonical_block_to_claude(
);
out.insert("name".to_string(), Value::String(name.clone()));
out.insert("input".to_string(), input);
out.extend(namespace_extension_object(extensions, "claude", &out));
out.extend(namespace_extension_object(
extensions,
CLAUDE_EXTENSION_NAMESPACE,
&out,
));
Some(Some(Value::Object(out)))
}
CanonicalContentBlock::ToolResult {
@@ -6026,7 +6141,11 @@ pub(crate) fn canonical_block_to_claude(
if *is_error {
out.insert("is_error".to_string(), Value::Bool(true));
}
out.extend(namespace_extension_object(extensions, "claude", &out));
out.extend(namespace_extension_object(
extensions,
CLAUDE_EXTENSION_NAMESPACE,
&out,
));
Some(Some(Value::Object(out)))
}
CanonicalContentBlock::Unknown {
@@ -7091,6 +7210,8 @@ const GEMINI_MAPPED_GENERATION_CONFIG_KEYS: &[&str] = &[
"response_mime_type",
"responseSchema",
"response_schema",
"responseJsonSchema",
"response_json_schema",
"responseModalities",
"response_modalities",
];
@@ -8370,7 +8491,8 @@ mod tests {
let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false)
.expect("openai responses request");
assert_eq!(rebuilt["input"][0]["type"], "reasoning");
assert_eq!(rebuilt["input"][0]["summary"][0]["text"], "think");
assert_eq!(rebuilt["input"][0]["content"][0]["type"], "reasoning_text");
assert_eq!(rebuilt["input"][0]["content"][0]["text"], "think");
assert_eq!(rebuilt["input"][0]["encrypted_content"], "enc_reasoning");
assert_eq!(rebuilt["input"][1]["type"], "message");
assert_eq!(rebuilt["input"][1]["content"][0]["text"], "done");
@@ -9317,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 = [
@@ -74,6 +74,14 @@ pub enum CanonicalStreamEvent {
name: Option<String>,
content: String,
},
/// Provider-neutral source citations for the answer text streamed so far.
///
/// Emitted once, just before `Finish`, by providers that ground an answer
/// server-side and report the evidence as metadata instead of a tool call.
/// Each entry carries `url` plus optional `title`, `cited_text` and
/// `start_index`/`end_index` character offsets; every target renders them
/// into its own family's citation shape.
Citations(Vec<Value>),
UnknownEvent(Value),
Finish {
finish_reason: Option<String>,
@@ -1,3 +1,4 @@
use std::borrow::Cow;
use std::collections::BTreeMap;
use serde_json::Value;
@@ -354,7 +355,7 @@ enum ProviderPrivateStreamNormalizeMode {
}
pub struct ProviderPrivateStreamNormalizer<'a> {
report_context: &'a Value,
report_context: Cow<'a, Value>,
buffered: Vec<u8>,
current_event_type: Option<String>,
mode: ProviderPrivateStreamNormalizeMode,
@@ -401,7 +402,7 @@ pub fn maybe_build_provider_private_stream_normalizer<'a>(
return None;
};
Some(ProviderPrivateStreamNormalizer {
report_context,
report_context: Cow::Borrowed(report_context),
buffered: Vec::new(),
current_event_type: None,
mode,
@@ -422,10 +423,20 @@ pub fn extract_provider_private_stream_error_body(
}
impl ProviderPrivateStreamNormalizer<'_> {
/// Move parser state across task boundaries without replaying captured bytes.
pub fn into_owned(self) -> ProviderPrivateStreamNormalizer<'static> {
ProviderPrivateStreamNormalizer {
report_context: Cow::Owned(self.report_context.into_owned()),
buffered: self.buffered,
current_event_type: self.current_event_type,
mode: self.mode,
}
}
pub fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match &mut self.mode {
ProviderPrivateStreamNormalizeMode::KiroToClaudeCli(state) => {
state.push_chunk(self.report_context, chunk)
state.push_chunk(self.report_context.as_ref(), chunk)
}
ProviderPrivateStreamNormalizeMode::EnvelopeUnwrap => {
let next_len = self
@@ -441,7 +452,7 @@ impl ProviderPrivateStreamNormalizer<'_> {
)));
}
self.buffered.extend_from_slice(chunk);
if report_context_is_windsurf_envelope(self.report_context)
if report_context_is_windsurf_envelope(self.report_context.as_ref())
&& buffer_looks_like_connect_frame(&self.buffered)
{
return drain_windsurf_connect_json_frames(&mut self.buffered);
@@ -451,7 +462,7 @@ impl ProviderPrivateStreamNormalizer<'_> {
let line = self.buffered.drain(..=line_end).collect::<Vec<_>>();
output.extend(
transform_provider_private_stream_line_with_event_state(
self.report_context,
self.report_context.as_ref(),
line,
&mut self.current_event_type,
)
@@ -466,20 +477,20 @@ impl ProviderPrivateStreamNormalizer<'_> {
pub fn finish(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match &mut self.mode {
ProviderPrivateStreamNormalizeMode::KiroToClaudeCli(state) => {
state.finish(self.report_context)
state.finish(self.report_context.as_ref())
}
ProviderPrivateStreamNormalizeMode::EnvelopeUnwrap => {
if self.buffered.is_empty() {
return Ok(Vec::new());
}
if report_context_is_windsurf_envelope(self.report_context)
if report_context_is_windsurf_envelope(self.report_context.as_ref())
&& buffer_looks_like_connect_frame(&self.buffered)
{
return drain_windsurf_connect_json_frames(&mut self.buffered);
}
let line = std::mem::take(&mut self.buffered);
transform_provider_private_stream_line_with_event_state(
self.report_context,
self.report_context.as_ref(),
line,
&mut self.current_event_type,
)
@@ -939,7 +950,7 @@ fn postprocess_private_response_value(data: &mut Value, report_context: &Value)
#[cfg(test)]
mod tests {
use serde_json::json;
use serde_json::{json, Value};
use super::{
extract_provider_private_stream_error_body, maybe_build_provider_private_stream_normalizer,
@@ -1116,6 +1127,44 @@ mod tests {
assert!(text.contains(r#""content":"chunk""#));
}
#[test]
fn owned_handoff_preserves_private_binary_frame() {
let text = "frame".repeat(10_000);
let framed = connect_json_frame(
0,
&serde_json::to_vec(&json!({
"responseId":"ws-handoff", "response":{"text":text}
}))
.unwrap(),
);
let split = 17_735;
let mut normalizer = {
let context = json!({"has_envelope":true,
"envelope_name":"windsurf:GetChatMessage", "provider_api_format":"openai:chat"});
let mut normalizer =
maybe_build_provider_private_stream_normalizer(Some(&context)).unwrap();
assert!(normalizer.push_chunk(&framed[..split]).unwrap().is_empty());
normalizer.into_owned()
};
let mut output = normalizer.push_chunk(&framed[split..]).unwrap();
output.extend(normalizer.finish().unwrap());
let output = String::from_utf8(output).unwrap();
let events: Vec<Value> = output
.lines()
.filter_map(|l| l.strip_prefix("data: "))
.filter(|p| *p != "[DONE]")
.map(|p| serde_json::from_str(p).unwrap())
.collect();
let recovered: String = events
.iter()
.filter_map(|e| {
e.pointer("/choices/0/delta/content")
.and_then(Value::as_str)
})
.collect();
assert_eq!(recovered, text);
}
#[test]
fn unwraps_windsurf_connect_json_stream_frames() {
let report_context = json!({