mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 10:57:03 +08:00
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:
@@ -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!({
|
||||
|
||||
@@ -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!({
|
||||
|
||||
Reference in New Issue
Block a user