Merge remote-tracking branch 'zhefox/main' into zhefox-main

# Conflicts:
#	crates/aether-admin/src/provider/quota.rs
#	crates/aether-ai/formats/src/formats/openai/chat/stream.rs
#	crates/aether-ai/formats/src/formats/openai/responses/mod.rs
#	crates/aether-provider/pool/src/provider.rs
#	crates/aether-provider/pool/src/quota.rs
This commit is contained in:
zhefox
2026-09-02 15:25:27 +08:00
229 changed files with 51071 additions and 896 deletions
@@ -721,6 +721,7 @@ impl ClaudeClientEmitter {
out.extend(self.ensure_tool_block(index, &call_id, &name)?);
Ok(out)
}
CanonicalStreamEvent::ToolCallSignature { .. } => Ok(Vec::new()),
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let (call_id, name) = {
let state = self.tool_states.entry(index).or_default();
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{json, Map, Value};
@@ -9,7 +9,9 @@ use crate::{
map_openai_reasoning_effort_to_gemini_budget,
map_thinking_budget_to_openai_reasoning_effort,
},
shared::model_directives::{gemini_model_uses_thinking_level, ReasoningEffort},
shared::model_directives::{
gemini_model_supports_mixed_tools, gemini_model_uses_thinking_level, ReasoningEffort,
},
},
protocol::canonical::{
apply_gemini_request_extensions, canonical_extension_object_mut,
@@ -18,9 +20,9 @@ use crate::{
gemini_generation_config_extra, gemini_google_search_grounding,
gemini_response_format_to_canonical, gemini_system_to_canonical_instructions,
gemini_thinking_to_canonical, gemini_tool_choice_to_canonical, gemini_tools_to_canonical,
gemini_value_by_case, CanonicalContentBlock, CanonicalMessage, CanonicalRequest,
CanonicalResponseFormat, CanonicalRole, CanonicalToolChoice, CanonicalToolDefinition,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
gemini_value_by_case, is_cross_format_tool_result, CanonicalContentBlock, CanonicalMessage,
CanonicalRequest, CanonicalResponseFormat, CanonicalRole, CanonicalToolChoice,
CanonicalToolDefinition, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
};
@@ -187,9 +189,88 @@ pub fn to_raw(
) -> Option<Value> {
let mut output = canonical_to_gemini_request_body(canonical, mapped_model, upstream_is_stream)?;
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)?;
}
Some(output)
}
fn canonical_has_raw_gemini_tools(canonical: &CanonicalRequest) -> bool {
canonical
.extensions
.get("gemini")
.and_then(Value::as_object)
.is_some_and(|gemini| gemini.contains_key("raw_tools"))
}
fn enable_server_side_tool_invocations_for_mixed_tools(
output: &mut Value,
mapped_model: &str,
) -> Option<()> {
let output_object = output.as_object_mut()?;
let tools = output_object.get("tools").and_then(Value::as_array);
let Some(tools) = tools else {
return Some(());
};
if !gemini_tools_are_mixed(tools) {
return Some(());
}
if !gemini_model_supports_mixed_tools(mapped_model) {
return None;
}
ensure_server_side_tool_invocations_for_mixed_tools(output)
}
pub fn ensure_server_side_tool_invocations_for_mixed_tools(output: &mut Value) -> Option<()> {
let output_object = output.as_object_mut()?;
let tools = output_object.get("tools").and_then(Value::as_array);
let Some(tools) = tools else {
return Some(());
};
if !gemini_tools_are_mixed(tools) {
return Some(());
}
let tool_config = output_object
.entry("toolConfig".to_string())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()?;
tool_config.remove("include_server_side_tool_invocations");
tool_config.insert(
"includeServerSideToolInvocations".to_string(),
Value::Bool(true),
);
Some(())
}
pub(crate) fn canonical_has_mixed_gemini_tools(canonical: &CanonicalRequest) -> bool {
canonical_tools_to_gemini(canonical)
.and_then(|tools| tools.as_array().cloned())
.is_some_and(|tools| gemini_tools_are_mixed(&tools))
}
fn gemini_tools_are_mixed(tools: &[Value]) -> bool {
let has_function_declarations = tools.iter().any(|tool| {
tool.as_object().is_some_and(|tool| {
tool.get("functionDeclarations")
.or_else(|| tool.get("function_declarations"))
.and_then(Value::as_array)
.is_some_and(|declarations| !declarations.is_empty())
})
});
let has_builtin_tools = tools.iter().any(|tool| {
tool.as_object().is_some_and(|tool| {
tool.keys().any(|key| {
!matches!(
key.as_str(),
"functionDeclarations" | "function_declarations"
)
})
})
});
has_function_declarations && has_builtin_tools
}
fn canonical_to_gemini_request_body(
canonical: &CanonicalRequest,
mapped_model: &str,
@@ -244,13 +325,51 @@ fn canonical_system_instruction(canonical: &CanonicalRequest) -> Option<Value> {
fn canonical_messages_to_gemini_contents(messages: &[CanonicalMessage]) -> Option<Vec<Value>> {
let mut contents = Vec::new();
let mut tool_name_by_id = BTreeMap::new();
for message in messages {
let role = match message.role {
let mut pending_tool_use_ids = Vec::new();
let mut message_index = 0;
while message_index < messages.len() {
let role = match messages[message_index].role {
CanonicalRole::Assistant => "model",
CanonicalRole::System | CanonicalRole::Developer => continue,
CanonicalRole::System | CanonicalRole::Developer => {
message_index += 1;
continue;
}
CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => "user",
};
let parts = canonical_blocks_to_gemini_parts(&message.content, &mut tool_name_by_id)?;
let mut blocks = Vec::new();
while message_index < messages.len() {
let next_role = match messages[message_index].role {
CanonicalRole::Assistant => Some("model"),
CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => Some("user"),
CanonicalRole::System | CanonicalRole::Developer => None,
};
match next_role {
Some(next_role) if next_role == role => {
blocks.extend(messages[message_index].content.iter());
message_index += 1;
}
None => message_index += 1,
Some(_) => break,
}
}
let blocks = if role == "user" {
let aligned = align_gemini_tool_results(blocks, &pending_tool_use_ids);
pending_tool_use_ids.clear();
aligned
} else {
pending_tool_use_ids = blocks
.iter()
.filter_map(|block| match block {
CanonicalContentBlock::ToolUse { id, .. } if !id.trim().is_empty() => {
Some(id.clone())
}
_ => None,
})
.collect();
blocks
};
let parts = canonical_blocks_to_gemini_parts(&blocks, &mut tool_name_by_id)?;
if parts.is_empty() {
continue;
}
@@ -262,15 +381,87 @@ fn canonical_messages_to_gemini_contents(messages: &[CanonicalMessage]) -> Optio
Some(contents)
}
fn align_gemini_tool_results<'a>(
blocks: Vec<&'a CanonicalContentBlock>,
pending_tool_use_ids: &[String],
) -> Vec<&'a CanonicalContentBlock> {
if pending_tool_use_ids.is_empty() {
return blocks;
}
let result_indexes = blocks
.iter()
.enumerate()
.filter_map(|(index, block)| match block {
CanonicalContentBlock::ToolResult { extensions, .. }
if is_cross_format_tool_result(extensions) =>
{
Some(index)
}
CanonicalContentBlock::ToolResult { .. } => None,
_ => None,
})
.collect::<Vec<_>>();
if result_indexes.len() != pending_tool_use_ids.len()
|| blocks
.iter()
.filter(|block| matches!(block, CanonicalContentBlock::ToolResult { .. }))
.count()
!= result_indexes.len()
{
return blocks;
}
let mut ordered = Vec::with_capacity(blocks.len());
let mut used = vec![false; result_indexes.len()];
for pending_id in pending_tool_use_ids {
if pending_id.trim().is_empty() {
return blocks;
}
let Some((result_position, block_index)) =
result_indexes
.iter()
.enumerate()
.find(|(result_position, block_index)| {
if used[*result_position] {
return false;
}
matches!(
blocks[**block_index],
CanonicalContentBlock::ToolResult { ref tool_use_id, .. }
if tool_use_id == pending_id
)
})
else {
return blocks;
};
used[result_position] = true;
ordered.push(blocks[*block_index]);
}
ordered.extend(
blocks
.iter()
.copied()
.filter(|block| !matches!(block, CanonicalContentBlock::ToolResult { .. })),
);
ordered
}
fn canonical_blocks_to_gemini_parts(
blocks: &[CanonicalContentBlock],
blocks: &[&CanonicalContentBlock],
tool_name_by_id: &mut BTreeMap<String, String>,
) -> Option<Vec<Value>> {
let mut parts = Vec::new();
let mut saw_tool_use = false;
for block in blocks {
if let Some(part) = canonical_block_to_gemini_part(block, tool_name_by_id)? {
let is_first_tool_use =
matches!(block, CanonicalContentBlock::ToolUse { .. }) && !saw_tool_use;
if let Some(part) =
canonical_block_to_gemini_part(block, tool_name_by_id, is_first_tool_use)?
{
parts.push(part);
}
saw_tool_use |= matches!(block, CanonicalContentBlock::ToolUse { .. });
}
Some(parts)
}
@@ -278,6 +469,7 @@ fn canonical_blocks_to_gemini_parts(
fn canonical_block_to_gemini_part(
block: &CanonicalContentBlock,
tool_name_by_id: &mut BTreeMap<String, String>,
is_first_tool_use: bool,
) -> Option<Option<Value>> {
match block {
CanonicalContentBlock::Text { text, .. } => Some(Some(json!({ "text": text }))),
@@ -329,16 +521,37 @@ fn canonical_block_to_gemini_part(
})
})),
CanonicalContentBlock::ToolUse {
id, name, input, ..
id,
name,
input,
extensions,
} => {
tool_name_by_id.insert(id.clone(), name.clone());
Some(Some(json!({
let mut part = json!({
"functionCall": {
"id": id,
"name": name,
"args": gemini_function_args(input),
}
})))
});
let signature = extensions
.get("gemini")
.and_then(Value::as_object)
.and_then(|gemini| {
gemini
.get("thoughtSignature")
.or_else(|| gemini.get("thought_signature"))
})
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.or_else(|| is_first_tool_use.then_some("skip_thought_signature_validator"));
if let Some(signature) = signature {
part.as_object_mut()?.insert(
"thoughtSignature".to_string(),
Value::String(signature.to_string()),
);
}
Some(Some(part))
}
CanonicalContentBlock::ToolResult {
tool_use_id,
@@ -788,24 +1001,194 @@ fn insert_f64(output: &mut Map<String, Value>, key: &str, value: Option<f64>) {
}
fn clean_gemini_schema(value: &mut Value) {
match value {
Value::Object(object) => {
for inner in object.values_mut() {
clean_gemini_schema(inner);
}
if object.get("type").and_then(Value::as_str) == Some("object")
&& !object.contains_key("properties")
{
object.insert("properties".to_string(), Value::Object(Map::new()));
let root = value.clone();
*value = json_schema_to_gemini_schema(&root, &root, &mut BTreeSet::new());
}
fn json_schema_to_gemini_schema(
value: &Value,
root: &Value,
resolving_refs: &mut BTreeSet<String>,
) -> Value {
let Some(object) = value.as_object() else {
return json!({});
};
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
if let Some(pointer) = reference.strip_prefix('#') {
if resolving_refs.insert(reference.to_string()) {
if let Some(resolved) = root.pointer(pointer).and_then(Value::as_object) {
let mut merged = resolved.clone();
for (key, value) in object {
if key != "$ref" {
merged.insert(key.clone(), value.clone());
}
}
let schema = clean_gemini_schema_object(&merged, root, resolving_refs);
resolving_refs.remove(reference);
return Value::Object(schema);
}
resolving_refs.remove(reference);
}
}
Value::Array(items) => {
for item in items {
clean_gemini_schema(item);
}
Value::Object(clean_gemini_schema_object(object, root, resolving_refs))
}
fn clean_gemini_schema_object(
object: &Map<String, Value>,
root: &Value,
resolving_refs: &mut BTreeSet<String>,
) -> Map<String, Value> {
let mut schema = Map::new();
for key in ["title", "description", "format", "pattern"] {
if let Some(value) = object.get(key).filter(|value| value.is_string()) {
schema.insert(key.to_string(), value.clone());
}
}
for key in ["default", "example"] {
if let Some(value) = object.get(key) {
schema.insert(key.to_string(), value.clone());
}
}
for key in ["minimum", "maximum"] {
if let Some(value) = object.get(key).filter(|value| value.is_number()) {
schema.insert(key.to_string(), value.clone());
}
}
for key in [
"minItems",
"maxItems",
"minLength",
"maxLength",
"minProperties",
"maxProperties",
] {
if let Some(value) = object.get(key).and_then(gemini_int64_string) {
schema.insert(key.to_string(), Value::String(value));
}
}
if let Some(value) = object.get("nullable").filter(|value| value.is_boolean()) {
schema.insert("nullable".to_string(), value.clone());
}
for key in ["required", "propertyOrdering"] {
if let Some(values) = object.get(key).and_then(gemini_string_array) {
schema.insert(key.to_string(), values);
}
}
if let Some(values) = object.get("enum").and_then(Value::as_array) {
let values = values
.iter()
.filter(|value| value.is_string())
.cloned()
.collect::<Vec<_>>();
if !values.is_empty() {
schema.insert("enum".to_string(), Value::Array(values));
}
}
if let Some(properties) = object.get("properties").and_then(Value::as_object) {
schema.insert(
"properties".to_string(),
Value::Object(
properties
.iter()
.map(|(name, value)| {
(
name.clone(),
json_schema_to_gemini_schema(value, root, resolving_refs),
)
})
.collect(),
),
);
}
if let Some(items) = object.get("items") {
schema.insert(
"items".to_string(),
json_schema_to_gemini_schema(items, root, resolving_refs),
);
}
let explicit_any_of = object
.get("anyOf")
.or_else(|| object.get("oneOf"))
.and_then(Value::as_array)
.map(|items| {
Value::Array(
items
.iter()
.map(|item| json_schema_to_gemini_schema(item, root, resolving_refs))
.collect(),
)
});
if let Some(any_of) = explicit_any_of {
schema.insert("anyOf".to_string(), any_of);
}
match object.get("type") {
Some(Value::String(schema_type)) => {
schema.insert("type".to_string(), Value::String(schema_type.clone()));
}
Some(Value::Array(types)) => {
let mut non_null_types = types
.iter()
.filter_map(Value::as_str)
.filter(|schema_type| *schema_type != "null")
.map(ToOwned::to_owned)
.collect::<Vec<_>>();
let mut seen_types = BTreeSet::new();
non_null_types.retain(|schema_type| seen_types.insert(schema_type.clone()));
let nullable = types.iter().any(|value| value.as_str() == Some("null"));
match non_null_types.as_slice() {
[schema_type] => {
schema.insert("type".to_string(), Value::String(schema_type.clone()));
}
[] if nullable => {
schema.insert("type".to_string(), Value::String("null".to_string()));
}
[] => {}
_ if !schema.contains_key("anyOf") => {
schema.insert(
"anyOf".to_string(),
Value::Array(
non_null_types
.iter()
.map(|schema_type| json!({ "type": schema_type }))
.collect(),
),
);
}
_ => {}
}
if nullable && !non_null_types.is_empty() {
schema.insert("nullable".to_string(), Value::Bool(true));
}
}
_ => {}
}
if schema.get("type").and_then(Value::as_str) == Some("object")
&& !schema.contains_key("properties")
{
schema.insert("properties".to_string(), Value::Object(Map::new()));
}
schema
}
fn gemini_int64_string(value: &Value) -> Option<String> {
match value {
Value::String(value) => Some(value.clone()),
Value::Number(value) => Some(value.to_string()),
_ => None,
}
}
fn gemini_string_array(value: &Value) -> Option<Value> {
let values = value.as_array()?;
values.iter().all(Value::is_string).then(|| value.clone())
}
#[cfg(test)]
@@ -813,6 +1196,79 @@ mod tests {
use super::*;
use crate::CanonicalContentBlock;
#[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
}
},
"required": ["secret"],
"additionalProperties": false
}
},
"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(),
});
assert_eq!(
declaration["parameters"],
json!({
"type": "object",
"properties": {
"target": {
"anyOf": [
{
"type": "object",
"properties": {
"secret": {"type": "string"}
},
"required": ["secret"]
},
{"type": "null"}
]
},
"mode": {
"type": "string",
"nullable": true,
"enum": ["fast"]
},
"value": {
"anyOf": [
{"type": "string"},
{"type": "integer"}
]
}
}
})
);
}
#[test]
fn canonical_tool_result_to_gemini_request_preserves_function_response_id() {
let mut tool_name_by_id = BTreeMap::new();
@@ -828,6 +1284,7 @@ mod tests {
extensions: BTreeMap::new(),
},
&mut tool_name_by_id,
false,
)
.expect("part should be representable")
.expect("part should not be omitted");
@@ -844,4 +1301,26 @@ mod tests {
serde_json::json!({"result": {"ok": true}})
);
}
#[test]
fn mixed_builtin_and_function_tools_require_gemini_three() {
let canonical = CanonicalRequest {
model: "gemini-2.5-pro".to_string(),
tools: vec![CanonicalToolDefinition {
name: "save_result".to_string(),
description: None,
parameters: Some(json!({"type": "object"})),
strict: None,
extensions: BTreeMap::new(),
}],
extensions: BTreeMap::from([(
"gemini".to_string(),
json!({"builtin_tools": [{"googleSearch": {}}]}),
)]),
..CanonicalRequest::default()
};
assert!(to_raw(&canonical, "gemini-2.5-pro", false).is_none());
assert!(to_raw(&canonical, "gemini-3-flash-preview", false).is_some());
}
}
@@ -12,6 +12,7 @@ struct GeminiProviderToolState {
call_id: String,
name: String,
arguments: String,
thought_signature: String,
started_emitted: bool,
}
@@ -254,6 +255,16 @@ impl GeminiProviderState {
.and_then(Value::as_str)
.unwrap_or(tool_state.name.as_str())
.to_string();
if let Some(signature) = reasoning_signature {
if tool_state.thought_signature != signature {
tool_state.thought_signature = signature.clone();
out.push(CanonicalStreamFrame {
id: id.clone(),
model: model.clone(),
event: CanonicalStreamEvent::ToolCallSignature { index, signature },
});
}
}
if !tool_state.started_emitted {
out.push(CanonicalStreamFrame {
id: id.clone(),
@@ -360,6 +371,7 @@ struct GeminiClientToolState {
call_id: String,
name: String,
arguments: String,
thought_signature: String,
emitted: bool,
}
@@ -434,7 +446,7 @@ impl GeminiClientEmitter {
let args_value = parse_json_arguments_value(&tool_call.arguments)
.unwrap_or_else(|| Value::Object(Map::new()));
tool_call.emitted = true;
pending.push(json!({
let mut part = json!({
"functionCall": {
"id": if tool_call.call_id.is_empty() {
build_generated_tool_call_id(*index)
@@ -448,7 +460,11 @@ impl GeminiClientEmitter {
},
"args": args_value,
}
}));
});
if !tool_call.thought_signature.is_empty() {
part["thoughtSignature"] = Value::String(tool_call.thought_signature.clone());
}
pending.push(part);
}
for part in pending {
out.extend(self.emit_candidate(vec![part], None, None)?);
@@ -505,6 +521,10 @@ impl GeminiClientEmitter {
state.name = name;
Ok(Vec::new())
}
CanonicalStreamEvent::ToolCallSignature { index, signature } => {
self.tool_calls.entry(index).or_default().thought_signature = signature;
Ok(Vec::new())
}
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let emitted_part = {
let state = self.tool_calls.entry(index).or_default();
@@ -515,7 +535,7 @@ impl GeminiClientEmitter {
let args_value = parse_json_arguments_value(&state.arguments);
args_value.map(|args_value| {
state.emitted = true;
json!({
let mut part = json!({
"functionCall": {
"id": if state.call_id.is_empty() {
build_generated_tool_call_id(index)
@@ -529,7 +549,12 @@ impl GeminiClientEmitter {
},
"args": args_value,
}
})
});
if !state.thought_signature.is_empty() {
part["thoughtSignature"] =
Value::String(state.thought_signature.clone());
}
part
})
}
};
@@ -972,6 +997,57 @@ mod tests {
)));
}
#[test]
fn gemini_provider_state_preserves_function_call_thought_signature() {
let mut state = GeminiProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
&report_context,
data_line(json!({
"responseId": "resp_signed_tool_123",
"modelVersion": "gemini-3-flash-preview",
"candidates": [{
"index": 0,
"content": {
"parts": [{
"functionCall": {
"id": "call_123",
"name": "lookup",
"args": {"query": "rust"}
},
"thoughtSignature": "opaque-tool-signature"
}]
}
}]
})),
)
.expect("signed function call should parse");
let signature_index = frames
.iter()
.position(|frame| {
matches!(
frame.event,
CanonicalStreamEvent::ToolCallSignature {
index: 0,
ref signature,
} if signature == "opaque-tool-signature"
)
})
.expect("tool signature event");
let call_index = frames
.iter()
.position(|frame| {
matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart { index: 0, .. }
)
})
.expect("tool call start event");
assert!(signature_index < call_index);
}
#[test]
fn gemini_client_emitter_marks_reasoning_parts_as_thoughts() {
let mut emitter = GeminiClientEmitter::default();
@@ -1,7 +1,7 @@
use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
formats::{context::FormatContext, openai::namespace::NamespaceToolAliases},
protocol::canonical::{
canonical_extension_object_mut, canonical_message_to_openai_chat_messages,
canonical_response_format_to_openai, canonical_tool_choice_to_openai,
@@ -11,8 +11,8 @@ use crate::{
openai_response_format_to_canonical, openai_responses_extension, openai_role_to_canonical,
openai_tool_choice_raw_to_chat, openai_tool_choice_to_canonical, openai_tools_to_canonical,
write_openai_generation_config, CanonicalContentBlock, CanonicalInstruction,
CanonicalRequest, CanonicalRole, CanonicalThinkingConfig, CanonicalToolChoice,
CanonicalToolDefinition, OPENAI_RESPONSES_EXTENSION_NAMESPACE,
CanonicalMessage, CanonicalRequest, CanonicalRole, CanonicalThinkingConfig,
CanonicalToolChoice, CanonicalToolDefinition, OPENAI_RESPONSES_EXTENSION_NAMESPACE,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
};
@@ -22,14 +22,27 @@ pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
if canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(request) {
return None;
}
let mut body = to_raw(request);
let mut body = to_raw(request)?;
force_stream_options(&mut body, ctx.upstream_is_stream);
Some(body)
}
pub(crate) fn to_raw(canonical: &CanonicalRequest) -> Option<Value> {
let namespace_tool_aliases = NamespaceToolAliases::from_canonical_tools(&canonical.tools);
if canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(canonical)
|| canonical_request_has_unrepresentable_namespace_tools_for_openai_chat(
canonical,
&namespace_tool_aliases,
)
{
return None;
}
Some(to_raw_with_namespace_aliases(
canonical,
&namespace_tool_aliases,
))
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
let request = body_json.as_object()?;
let mut canonical = CanonicalRequest {
@@ -135,7 +148,10 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
Some(canonical)
}
pub fn to_raw(canonical: &CanonicalRequest) -> Value {
fn to_raw_with_namespace_aliases(
canonical: &CanonicalRequest,
namespace_tool_aliases: &NamespaceToolAliases,
) -> Value {
let mut output = serde_json::Map::new();
if !canonical.model.trim().is_empty() {
output.insert("model".to_string(), Value::String(canonical.model.clone()));
@@ -155,24 +171,31 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value {
}
}
for message in &canonical.messages {
messages.extend(canonical_message_to_openai_chat_messages(message));
let mut message = message.clone();
rewrite_namespaced_tool_uses_for_openai_chat(&mut message, namespace_tool_aliases);
messages.extend(canonical_message_to_openai_chat_messages(&message));
}
output.insert("messages".to_string(), Value::Array(messages));
write_openai_generation_config(&mut output, &canonical.generation);
if !canonical.tools.is_empty() {
output.insert(
"tools".to_string(),
Value::Array(
canonical
.tools
.iter()
.map(canonical_tool_to_openai)
.collect(),
),
);
let mut tools = Vec::new();
for (tool_index, tool) in canonical.tools.iter().enumerate() {
if namespace_tool_aliases.is_representable_namespace_tool(tool_index) {
tools.extend(
namespace_tool_aliases
.tools_for_source(tool_index)
.map(|tool| tool.to_openai_chat_tool()),
);
} else {
tools.push(canonical_tool_to_openai(tool));
}
}
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) = canonical_tool_choice_to_openai_for_request(canonical) {
if let Some(tool_choice) =
canonical_tool_choice_to_openai_for_request(canonical, namespace_tool_aliases)
{
output.insert("tool_choice".to_string(), tool_choice);
}
if let Some(value) = canonical.parallel_tool_calls {
@@ -224,12 +247,53 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value {
Value::Object(output)
}
fn canonical_tool_choice_to_openai_for_request(canonical: &CanonicalRequest) -> Option<Value> {
fn canonical_tool_choice_to_openai_for_request(
canonical: &CanonicalRequest,
namespace_tool_aliases: &NamespaceToolAliases,
) -> Option<Value> {
canonical
.tool_choice
.as_ref()
.map(|tool_choice| canonical_tool_choice_to_openai_for_tools(tool_choice, &canonical.tools))
.or_else(|| raw_tool_choice_extension(canonical).map(openai_tool_choice_raw_to_chat))
.map(|tool_choice| {
if let CanonicalToolChoice::Tool { name } = tool_choice {
if let NamespaceNameResolution::Alias(alias) =
resolve_namespace_child_name(name, &canonical.tools, namespace_tool_aliases)
{
return json!({
"type": "function",
"function": { "name": alias },
});
}
}
canonical_tool_choice_to_openai_for_tools(tool_choice, &canonical.tools)
})
.or_else(|| {
raw_tool_choice_extension(canonical).and_then(|raw| {
raw_tool_choice_to_openai_chat_for_request(
raw,
&canonical.tools,
namespace_tool_aliases,
)
})
})
}
fn raw_tool_choice_to_openai_chat_for_request(
raw: &Value,
tools: &[CanonicalToolDefinition],
aliases: &NamespaceToolAliases,
) -> Option<Value> {
let mut choice = openai_tool_choice_raw_to_chat(raw);
rewrite_namespace_tool_choice_names(&mut choice, tools, aliases).then_some(choice)
}
pub(crate) fn raw_tool_choice_extension_is_representable_for_openai_chat(
canonical: &CanonicalRequest,
) -> bool {
let aliases = NamespaceToolAliases::from_canonical_tools(&canonical.tools);
raw_tool_choice_extension(canonical).is_some_and(|raw| {
raw_tool_choice_to_openai_chat_for_request(raw, &canonical.tools, &aliases).is_some()
})
}
fn canonical_tool_choice_to_openai_for_tools(
@@ -251,6 +315,293 @@ fn canonical_tool_choice_to_openai_for_tools(
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NamespaceNameResolution<'a> {
Unchanged,
Alias(&'a str),
Ambiguous,
}
fn resolve_namespace_child_name<'a>(
name: &'a str,
tools: &[CanonicalToolDefinition],
aliases: &'a NamespaceToolAliases,
) -> NamespaceNameResolution<'a> {
let namespace_children = aliases.namespace_children_named(name).collect::<Vec<_>>();
let ordinary_matches = tools
.iter()
.enumerate()
.filter(|(index, tool)| !aliases.is_namespace_tool(*index) && tool.name == name)
.count();
let namespace_parent_matches = tools
.iter()
.enumerate()
.filter(|(index, tool)| aliases.is_namespace_tool(*index) && tool.name == name)
.count();
match (
namespace_children.as_slice(),
ordinary_matches,
namespace_parent_matches,
) {
([], 0, 0) | ([], 1, _) => NamespaceNameResolution::Unchanged,
([], 0, _) => NamespaceNameResolution::Ambiguous,
([child], 0, _) => NamespaceNameResolution::Alias(child.chat_name.as_str()),
_ => NamespaceNameResolution::Ambiguous,
}
}
fn rewrite_namespace_tool_choice_names(
choice: &mut Value,
tools: &[CanonicalToolDefinition],
aliases: &NamespaceToolAliases,
) -> bool {
if let Some(choice) = choice.as_str() {
return matches!(choice, "none" | "auto" | "required");
}
let Some(choice) = choice.as_object_mut() else {
return false;
};
let choice_type = choice
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match choice_type {
"function" => {
if !object_has_exact_keys(choice, &["type", "function"]) {
return false;
}
choice
.get_mut("function")
.and_then(Value::as_object_mut)
.is_some_and(|function| {
object_has_exact_keys(function, &["name"])
&& rewrite_named_function_choice(function, tools, aliases)
})
}
"custom" => {
object_has_exact_keys(choice, &["type", "custom"])
&& choice
.get("custom")
.and_then(Value::as_object)
.is_some_and(|custom| {
object_has_exact_keys(custom, &["name"]) && valid_named_choice(custom)
})
}
"allowed_tools" => {
if !object_has_exact_keys(choice, &["type", "allowed_tools"]) {
return false;
}
let Some(allowed) = choice
.get_mut("allowed_tools")
.and_then(Value::as_object_mut)
else {
return false;
};
if !object_has_exact_keys(allowed, &["mode", "tools"])
|| !allowed
.get("mode")
.and_then(Value::as_str)
.is_some_and(|mode| matches!(mode, "auto" | "required"))
{
return false;
}
let Some(allowed_tools) = allowed.get_mut("tools").and_then(Value::as_array_mut) else {
return false;
};
allowed_tools.iter_mut().all(|tool| {
let Some(tool) = tool.as_object_mut() else {
return false;
};
match tool.get("type").and_then(Value::as_str) {
Some("function") => {
object_has_exact_keys(tool, &["type", "function"])
&& tool
.get_mut("function")
.and_then(Value::as_object_mut)
.is_some_and(|function| {
object_has_exact_keys(function, &["name"])
&& rewrite_named_function_choice(function, tools, aliases)
})
}
Some("custom") => {
object_has_exact_keys(tool, &["type", "custom"])
&& tool
.get("custom")
.and_then(Value::as_object)
.is_some_and(|custom| {
object_has_exact_keys(custom, &["name"])
&& valid_named_choice(custom)
})
}
_ => false,
}
})
}
_ => false,
}
}
fn object_has_exact_keys(object: &Map<String, Value>, keys: &[&str]) -> bool {
object.len() == keys.len() && object.keys().all(|key| keys.contains(&key.as_str()))
}
fn valid_named_choice(choice: &Map<String, Value>) -> bool {
choice
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|name| !name.is_empty())
}
fn rewrite_named_function_choice(
function: &mut Map<String, Value>,
tools: &[CanonicalToolDefinition],
aliases: &NamespaceToolAliases,
) -> bool {
let Some(name) = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToOwned::to_owned)
else {
return false;
};
match resolve_namespace_child_name(&name, tools, aliases) {
NamespaceNameResolution::Unchanged => true,
NamespaceNameResolution::Alias(alias) => {
function.insert("name".to_string(), Value::String(alias.to_string()));
true
}
NamespaceNameResolution::Ambiguous => false,
}
}
fn rewrite_namespaced_tool_uses_for_openai_chat(
message: &mut CanonicalMessage,
aliases: &NamespaceToolAliases,
) {
for block in &mut message.content {
let CanonicalContentBlock::ToolUse {
name, extensions, ..
} = block
else {
continue;
};
let NamespaceField::Name(namespace) = namespace_field(extensions) else {
continue;
};
if let Some(alias) = aliases.chat_name(namespace, name) {
*name = alias.to_string();
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NamespaceField<'a> {
Absent,
Name(&'a str),
Invalid,
}
fn namespace_field(extensions: &std::collections::BTreeMap<String, Value>) -> NamespaceField<'_> {
let Some(responses) = openai_responses_extension(extensions).and_then(Value::as_object) else {
return NamespaceField::Absent;
};
let Some(namespace) = responses.get("namespace") else {
return NamespaceField::Absent;
};
namespace
.as_str()
.map(str::trim)
.filter(|namespace| !namespace.is_empty())
.map(NamespaceField::Name)
.unwrap_or(NamespaceField::Invalid)
}
fn canonical_request_has_unrepresentable_namespace_tools_for_openai_chat(
request: &CanonicalRequest,
aliases: &NamespaceToolAliases,
) -> bool {
if aliases.has_invalid_namespace_tools() {
return true;
}
if let Some(CanonicalToolChoice::Tool { name }) = &request.tool_choice {
if resolve_namespace_child_name(name, &request.tools, aliases)
== NamespaceNameResolution::Ambiguous
{
return true;
}
} else if let Some(raw) = raw_tool_choice_extension(request) {
let mut choice = openai_tool_choice_raw_to_chat(raw);
if !rewrite_namespace_tool_choice_names(&mut choice, &request.tools, aliases) {
return true;
}
}
request.messages.iter().any(|message| {
message.content.iter().any(|block| {
let CanonicalContentBlock::ToolUse {
name, extensions, ..
} = block
else {
return false;
};
match namespace_field(extensions) {
NamespaceField::Absent => false,
NamespaceField::Invalid => true,
NamespaceField::Name(namespace) => {
aliases.chat_name(namespace, name).is_none()
|| !namespace_tool_use_sidecars_are_chat_representable(
extensions, namespace,
)
}
}
})
})
}
fn namespace_tool_use_sidecars_are_chat_representable(
extensions: &std::collections::BTreeMap<String, Value>,
expected_namespace: &str,
) -> bool {
[
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
]
.into_iter()
.filter_map(|provider_namespace| extensions.get(provider_namespace))
.all(|provider_fields| {
let Some(provider_fields) = provider_fields.as_object() else {
return false;
};
if !provider_fields
.keys()
.all(|key| matches!(key.as_str(), "namespace" | "item_id" | "status"))
{
return false;
}
let namespace_is_consistent = provider_fields.get("namespace").is_none_or(|namespace| {
namespace
.as_str()
.map(str::trim)
.is_some_and(|namespace| namespace == expected_namespace)
});
let item_id_is_valid = provider_fields.get("item_id").is_none_or(|item_id| {
item_id
.as_str()
.map(str::trim)
.is_some_and(|item_id| !item_id.is_empty())
});
let status_is_discardable = provider_fields
.get("status")
.is_none_or(|status| status.as_str() == Some("completed"));
namespace_is_consistent && item_id_is_valid && status_is_discardable
})
}
fn raw_tool_choice_extension(canonical: &CanonicalRequest) -> Option<&Value> {
canonical
.extensions
@@ -415,3 +766,347 @@ fn force_stream_options(body: &mut Value, upstream_is_stream: bool) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::formats::openai::responses;
#[test]
fn responses_namespace_expands_definition_and_maps_history_and_named_choice() {
let body = json!({
"model": "gpt-source",
"input": [
{
"type": "function_call",
"id": "fc_report",
"call_id": "call_report",
"namespace": "mcp__reports",
"name": "write_report",
"arguments": "{\"report_path\":\"reports/finding.md\"}"
},
{
"type": "function_call_output",
"call_id": "call_report",
"output": "created"
}
],
"tools": [{
"type": "namespace",
"name": "mcp__reports",
"description": "Reporting tools",
"tools": [{
"type": "function",
"name": "write_report",
"description": "Create a report",
"parameters": {
"type": "object",
"properties": {"report_path": {"type": "string"}},
"required": ["report_path"],
"additionalProperties": false
},
"strict": true
}]
}],
"tool_choice": {"type": "function", "name": "write_report"}
});
let canonical = responses::request::from_raw(&body).expect("Responses request");
let chat = to(&canonical, &FormatContext::default()).expect("Chat request");
assert_eq!(chat["tools"].as_array().map(Vec::len), Some(1));
assert_eq!(chat["tools"][0]["function"]["name"], "write_report");
assert_eq!(
chat["tools"][0]["function"]["parameters"],
body["tools"][0]["tools"][0]["parameters"]
);
assert_eq!(chat["tools"][0]["function"]["strict"], true);
assert_eq!(chat["tool_choice"]["function"]["name"], "write_report");
let historical_call = chat["messages"]
.as_array()
.and_then(|messages| {
messages
.iter()
.find(|message| message.get("tool_calls").is_some())
})
.expect("historical tool call");
assert_eq!(
historical_call["tool_calls"][0]["function"]["name"],
"write_report"
);
}
#[test]
fn responses_namespace_named_choice_fails_closed_when_child_name_is_ambiguous() {
let body = json!({
"model": "gpt-source",
"input": "write it",
"tools": [
{
"type": "namespace",
"name": "first",
"description": "First tools",
"tools": [{
"type": "function",
"name": "write_report",
"parameters": {"type": "object"}
}]
},
{
"type": "namespace",
"name": "second",
"description": "Second tools",
"tools": [{
"type": "function",
"name": "write_report",
"parameters": {"type": "object"}
}]
}
],
"tool_choice": {"type": "function", "name": "write_report"}
});
let canonical = responses::request::from_raw(&body).expect("Responses request");
assert!(to(&canonical, &FormatContext::default()).is_none());
}
#[test]
fn responses_namespace_named_choice_allows_parent_and_child_to_share_a_name() {
let body = json!({
"model": "gpt-source",
"input": "write it",
"tools": [{
"type": "namespace",
"name": "reports",
"description": "Reporting tools",
"tools": [{
"type": "function",
"name": "reports",
"parameters": {"type": "object"}
}]
}],
"tool_choice": {"type": "function", "name": "reports"}
});
let canonical = responses::request::from_raw(&body).expect("Responses request");
let chat = to(&canonical, &FormatContext::default()).expect("Chat request");
assert_eq!(chat["tools"][0]["function"]["name"], "reports");
assert_eq!(chat["tool_choice"]["function"]["name"], "reports");
}
#[test]
fn responses_namespace_allowed_tools_choice_uses_the_expanded_alias() {
let long_name = format!("write_report_{}", "x".repeat(80));
let body = json!({
"model": "gpt-source",
"input": "write it",
"tools": [{
"type": "namespace",
"name": "reports",
"description": "Reporting tools",
"tools": [{
"type": "function",
"name": long_name,
"parameters": {"type": "object"}
}]
}],
"tool_choice": {
"type": "allowed_tools",
"mode": "required",
"tools": [{"type": "function", "name": long_name}]
}
});
let canonical = responses::request::from_raw(&body).expect("Responses request");
let chat = to(&canonical, &FormatContext::default()).expect("Chat request");
let definition_alias = chat["tools"][0]["function"]["name"]
.as_str()
.expect("definition alias");
let choice_alias = chat["tool_choice"]["allowed_tools"]["tools"][0]["function"]["name"]
.as_str()
.expect("choice alias");
assert_eq!(choice_alias, definition_alias);
assert!(definition_alias.len() <= 64);
}
#[test]
fn responses_namespace_tool_choices_fail_closed_when_the_shape_is_malformed() {
let choices = [
json!({"type": "function"}),
json!({
"type": "allowed_tools",
"tools": [{"type": "function", "name": "write_report"}]
}),
json!({"type": "allowed_tools", "mode": "required"}),
json!({
"type": "allowed_tools",
"mode": "required",
"tools": [42]
}),
json!({
"type": "allowed_tools",
"mode": "required",
"tools": [{"type": "future_tool", "name": "write_report"}]
}),
];
for tool_choice in choices {
let body = json!({
"model": "gpt-source",
"input": "write it",
"tools": [{
"type": "namespace",
"name": "reports",
"description": "Reporting tools",
"tools": [{
"type": "function",
"name": "write_report",
"parameters": {"type": "object"}
}]
}],
"tool_choice": tool_choice
});
let canonical = responses::request::from_raw(&body).expect("Responses request");
assert!(to_raw(&canonical).is_none());
assert!(to(&canonical, &FormatContext::default()).is_none());
}
}
#[test]
fn responses_namespace_alias_avoids_ordinary_function_name_collisions() {
let body = json!({
"model": "gpt-source",
"input": [{
"type": "function_call",
"call_id": "call_report",
"namespace": "reports",
"name": "write_report",
"arguments": "{}"
}],
"tools": [
{
"type": "function",
"name": "write_report",
"parameters": {"type": "object"}
},
{
"type": "function",
"name": "reports__write_report",
"parameters": {"type": "object"}
},
{
"type": "namespace",
"name": "reports",
"description": "Reporting tools",
"tools": [{
"type": "function",
"name": "write_report",
"parameters": {"type": "object"}
}]
}
]
});
let canonical = responses::request::from_raw(&body).expect("Responses request");
let chat = to(&canonical, &FormatContext::default()).expect("Chat request");
let names = chat["tools"]
.as_array()
.expect("Chat tools")
.iter()
.map(|tool| {
tool["function"]["name"]
.as_str()
.expect("Chat function name")
})
.collect::<std::collections::BTreeSet<_>>();
let namespace_alias = chat["tools"][2]["function"]["name"]
.as_str()
.expect("namespace alias");
assert_eq!(names.len(), 3);
assert!(names.contains("write_report"));
assert!(names.contains("reports__write_report"));
assert!(namespace_alias.starts_with("aether_ns_"));
assert!(names.iter().all(|name| {
name.len() <= 64
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
}));
assert_eq!(
chat["messages"][0]["tool_calls"][0]["function"]["name"],
namespace_alias
);
let mut ambiguous = body;
ambiguous["tool_choice"] = json!({"type": "function", "name": "write_report"});
let canonical = responses::request::from_raw(&ambiguous).expect("Responses request");
assert!(to(&canonical, &FormatContext::default()).is_none());
}
#[test]
fn responses_namespace_history_fails_closed_when_identity_is_unknown() {
let body = json!({
"model": "gpt-source",
"input": [{
"type": "function_call",
"call_id": "call_report",
"namespace": "unknown_namespace",
"name": "write_report",
"arguments": "{}"
}],
"tools": [{
"type": "namespace",
"name": "reports",
"description": "Reporting tools",
"tools": [{
"type": "function",
"name": "write_report",
"parameters": {"type": "object"}
}]
}]
});
let canonical = responses::request::from_raw(&body).expect("Responses request");
assert!(to(&canonical, &FormatContext::default()).is_none());
}
#[test]
fn namespace_history_sidecars_require_exact_keys_and_consistent_namespaces() {
let valid = std::collections::BTreeMap::from([(
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
json!({
"namespace": "reports",
"item_id": "fc_report",
"status": "completed"
}),
)]);
assert!(namespace_tool_use_sidecars_are_chat_representable(
&valid, "reports"
));
let mut conflicting = valid.clone();
conflicting.insert(
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE.to_string(),
json!({
"namespace": "other_reports",
"item_id": "fc_report",
"status": "completed"
}),
);
assert!(!namespace_tool_use_sidecars_are_chat_representable(
&conflicting,
"reports"
));
let unknown = std::collections::BTreeMap::from([(
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
json!({
"namespace": "reports",
"caller": "future-semantic-owner"
}),
)]);
assert!(!namespace_tool_use_sidecars_are_chat_representable(
&unknown, "reports"
));
}
}
@@ -2,11 +2,15 @@ use std::collections::{BTreeMap, BTreeSet};
use serde_json::{json, Map, Value};
use crate::formats::openai::namespace::NamespaceToolAliases;
use crate::formats::openai::responses::{
openai_responses_message_item_id, openai_responses_synthetic_reasoning_item_id,
encode_gemini_tool_signature_carrier_with_direction,
openai_responses_message_item_id,
openai_responses_synthetic_reasoning_item_id,
response::{
ensure_modern_openai_responses_response_fields, openai_responses_current_timestamp,
},
GeminiToolSignatureCarrierDirection,
};
use crate::formats::shared::response::build_generated_tool_call_id;
use crate::formats::shared::sse::{encode_done_sse, encode_json_sse};
@@ -683,6 +687,52 @@ impl OpenAIResponsesProviderState {
}
}
fn resolve_function_call_chat_name(
report_context: &Value,
namespace: Option<&Value>,
incoming_name: Option<&str>,
existing_chat_name: Option<&str>,
) -> Result<Option<String>, ()> {
let incoming_name = incoming_name.map(str::trim).filter(|name| !name.is_empty());
let existing_chat_name = existing_chat_name
.map(str::trim)
.filter(|name| !name.is_empty());
let aliases = NamespaceToolAliases::from_report_context(report_context);
match namespace {
Some(Value::String(namespace)) if !namespace.trim().is_empty() => {
if let Some(child_name) = incoming_name {
return aliases
.chat_name(namespace.trim(), child_name)
.map(|chat_name| Some(chat_name.to_string()))
.ok_or(());
}
existing_chat_name
.filter(|chat_name| {
aliases
.responses_name(chat_name)
.is_some_and(|(existing_namespace, _)| {
existing_namespace == namespace.trim()
})
})
.map(|chat_name| Some(chat_name.to_string()))
.ok_or(())
}
Some(_) => Err(()),
None => {
if let (Some(child_name), Some(chat_name)) = (incoming_name, existing_chat_name) {
if aliases
.responses_name(chat_name)
.is_some_and(|(_, existing_child_name)| existing_child_name == child_name)
{
return Ok(Some(chat_name.to_string()));
}
}
Ok(incoming_name.or(existing_chat_name).map(ToOwned::to_owned))
}
}
}
fn emit_tool_call_item(
&mut self,
report_context: &Value,
@@ -698,7 +748,15 @@ impl OpenAIResponsesProviderState {
// sync Responses aggregator retains the original item verbatim. Treat these
// sidecars as recognized while continuing to fail closed for semantic fields
// (for example `caller`) that the canonical tool-call events cannot represent.
const EXECUTION_FIELDS: &[&str] = &["type", "id", "call_id", "status", "name", "arguments"];
const EXECUTION_FIELDS: &[&str] = &[
"type",
"id",
"call_id",
"status",
"namespace",
"name",
"arguments",
];
let has_chat_metadata_passthrough =
item.contains_key("internal_chat_message_metadata_passthrough");
let chat_metadata_target_supported = report_context
@@ -727,17 +785,28 @@ impl OpenAIResponsesProviderState {
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let index = self.tool_index_for_key(key, output_index);
let existing_chat_name = self.tool_calls.get(&index).map(|state| state.name.as_str());
let incoming_chat_name = match Self::resolve_function_call_chat_name(
report_context,
item.get("namespace"),
item.get("name").and_then(Value::as_str),
existing_chat_name,
) {
Ok(name) => name,
Err(()) => {
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
return;
}
};
let state = self.tool_calls.entry(index).or_default();
state.call_id = item
.get("call_id")
.and_then(Value::as_str)
.unwrap_or(state.call_id.as_str())
.to_string();
state.name = item
.get("name")
.and_then(Value::as_str)
.unwrap_or(state.name.as_str())
.to_string();
if let Some(name) = incoming_chat_name {
state.name = name;
}
let completed_arguments = item
.get("arguments")
.and_then(Value::as_str)
@@ -1590,13 +1659,12 @@ impl OpenAIResponsesProviderState {
self.emit_ready_function_call(report_context, &mut out, index);
}
"response.function_call_arguments.done" => {
let nested_item = value.get("item").and_then(Value::as_object);
let arguments = value
.get("arguments")
.and_then(Value::as_str)
.or_else(|| {
value
.get("item")
.and_then(Value::as_object)
nested_item
.and_then(|item| item.get("arguments"))
.and_then(Value::as_str)
})
@@ -1609,9 +1677,7 @@ impl OpenAIResponsesProviderState {
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| {
value
.get("item")
.and_then(Value::as_object)
nested_item
.and_then(|item| item.get("call_id").or_else(|| item.get("id")))
.and_then(Value::as_str)
.map(ToOwned::to_owned)
@@ -1621,31 +1687,42 @@ impl OpenAIResponsesProviderState {
.and_then(Value::as_u64)
.map(|value| value as usize);
let index = self.tool_index_for_key(key, output_index);
let incoming_name = value.get("name").and_then(Value::as_str).or_else(|| {
nested_item
.and_then(|item| item.get("name"))
.and_then(Value::as_str)
});
let namespace = value
.get("namespace")
.or_else(|| nested_item.and_then(|item| item.get("namespace")));
let existing_chat_name =
self.tool_calls.get(&index).map(|state| state.name.as_str());
let incoming_chat_name = match Self::resolve_function_call_chat_name(
report_context,
namespace,
incoming_name,
existing_chat_name,
) {
Ok(name) => name,
Err(()) => {
out.push(self.unknown_frame(report_context, value.clone()));
return Ok(out);
}
};
let state = self.tool_calls.entry(index).or_default();
state.call_id = value
.get("call_id")
.and_then(Value::as_str)
.or_else(|| {
value
.get("item")
.and_then(Value::as_object)
nested_item
.and_then(|item| item.get("call_id"))
.and_then(Value::as_str)
})
.unwrap_or(state.call_id.as_str())
.to_string();
state.name = value
.get("name")
.and_then(Value::as_str)
.or_else(|| {
value
.get("item")
.and_then(Value::as_object)
.and_then(|item| item.get("name"))
.and_then(Value::as_str)
})
.unwrap_or(state.name.as_str())
.to_string();
if let Some(name) = incoming_chat_name {
state.name = name;
}
Self::merge_tool_call_arguments(state, arguments);
self.emit_ready_function_call(report_context, &mut out, index);
}
@@ -1764,7 +1841,7 @@ impl OpenAIResponsesProviderState {
});
self.finished = true;
}
"keepalive" => {}
"keepalive" | "ping" => {}
event_type if openai_responses_stream_event_is_known_noop(event_type) => {
self.ensure_started(report_context, &mut out);
}
@@ -1856,7 +1933,10 @@ pub struct OpenAIChatClientEmitter {
struct OpenAIResponsesClientToolState {
call_id: String,
name: String,
namespace: Option<String>,
arguments: String,
thought_signature_carrier: Option<String>,
thought_signature_output_index: Option<usize>,
output_index: Option<usize>,
web_search: bool,
}
@@ -1915,6 +1995,7 @@ pub struct OpenAIResponsesClientEmitter {
opaque_output_items: BTreeMap<usize, Value>,
opaque_output_indexes: BTreeMap<String, usize>,
completed_history_response: Option<Value>,
namespace_tool_aliases: NamespaceToolAliases,
}
impl OpenAIChatClientEmitter {
@@ -2098,6 +2179,7 @@ impl OpenAIChatClientEmitter {
);
Ok(out)
}
CanonicalStreamEvent::ToolCallSignature { .. } => Ok(Vec::new()),
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let mut out = self.ensure_started()?;
let chat_index = self.chat_tool_call_index(index);
@@ -2212,6 +2294,13 @@ impl OpenAIChatClientEmitter {
}
impl OpenAIResponsesClientEmitter {
pub(crate) fn with_report_context(report_context: &Value) -> Self {
Self {
namespace_tool_aliases: NamespaceToolAliases::from_report_context(report_context),
..Self::default()
}
}
pub(crate) fn set_actual_service_tier(&mut self, value: Option<&str>) {
if value.is_some_and(|value| {
self.actual_service_tier
@@ -2678,20 +2767,26 @@ impl OpenAIResponsesClientEmitter {
"arguments": state.arguments.as_str(),
}),
)?);
let mut completed_item = json!({
"type": "function_call",
"id": item_id.clone(),
"call_id": call_id,
"name": name,
"arguments": state.arguments.as_str(),
"status": "completed",
});
if let (Some(namespace), Some(item)) =
(state.namespace.clone(), completed_item.as_object_mut())
{
item.insert("namespace".to_string(), Value::String(namespace));
}
out.extend(self.encode_response_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"response_id": self.response_id(),
"output_index": output_index,
"item": {
"type": "function_call",
"id": item_id.clone(),
"call_id": call_id,
"name": name,
"arguments": state.arguments.as_str(),
"status": "completed",
}
"item": completed_item,
}),
)?);
}
@@ -2829,6 +2924,24 @@ impl OpenAIResponsesClientEmitter {
));
}
for (index, state) in &self.tool_calls {
if let (Some(output_index), Some(carrier)) = (
state.thought_signature_output_index,
state.thought_signature_carrier.as_ref(),
) {
ordered_output.push((
output_index,
json!({
"type": "reasoning",
"id": openai_responses_synthetic_reasoning_item_id(
self.response_id(),
output_index,
),
"status": "completed",
"encrypted_content": carrier,
"summary": [],
}),
));
}
if let Some(output_index) = state.output_index {
let call_id = if state.call_id.is_empty() {
build_generated_tool_call_id(*index)
@@ -2851,21 +2964,24 @@ impl OpenAIResponsesClientEmitter {
));
continue;
}
ordered_output.push((
output_index,
json!({
"type": "function_call",
"id": item_id.clone(),
"call_id": call_id,
"name": if state.name.is_empty() {
"unknown".to_string()
} else {
state.name.clone()
},
"arguments": state.arguments.clone(),
"status": "completed",
}),
));
let mut item = json!({
"type": "function_call",
"id": item_id.clone(),
"call_id": call_id,
"name": if state.name.is_empty() {
"unknown".to_string()
} else {
state.name.clone()
},
"arguments": state.arguments.clone(),
"status": "completed",
});
if let (Some(namespace), Some(item)) =
(state.namespace.clone(), item.as_object_mut())
{
item.insert("namespace".to_string(), Value::String(namespace));
}
ordered_output.push((output_index, item));
}
}
for (index, state) in &self.tool_results {
@@ -3150,13 +3266,21 @@ impl OpenAIResponsesClientEmitter {
let output_index = self.ensure_tool_output_index(index);
let response_id = self.response_id().to_string();
let item_id = self.tool_call_item_id(index);
let namespaced_tool = self.namespace_tool_aliases.responses_name(&name);
let emitted_name = namespaced_tool
.map(|(_, child_name)| child_name.to_string())
.unwrap_or_else(|| name.clone());
let emitted_namespace = namespaced_tool.map(|(namespace, _)| namespace.to_string());
let is_namespaced_tool = namespaced_tool.is_some();
let state = self.tool_calls.entry(index).or_default();
state.call_id = call_id.clone();
state.name = name.clone();
state.web_search = is_responses_web_search_tool(&name);
state.name = emitted_name;
state.namespace = emitted_namespace;
state.web_search = !is_namespaced_tool && is_responses_web_search_tool(&name);
let emitted_call_id = state.call_id.clone();
let emitted_name = state.name.clone();
let item = if state.web_search {
let emitted_namespace = state.namespace.clone();
let mut item = if state.web_search {
json!({
"type": "web_search_call",
"id": item_id,
@@ -3176,6 +3300,9 @@ impl OpenAIResponsesClientEmitter {
"status": "in_progress",
})
};
if let (Some(namespace), Some(item)) = (emitted_namespace, item.as_object_mut()) {
item.insert("namespace".to_string(), Value::String(namespace));
}
out.extend(self.encode_response_event(
"response.output_item.added",
json!({
@@ -3187,6 +3314,69 @@ impl OpenAIResponsesClientEmitter {
)?);
Ok(out)
}
CanonicalStreamEvent::ToolCallSignature { index, signature } => {
let direction = if self
.tool_calls
.get(&index)
.and_then(|state| state.output_index)
.is_some()
{
GeminiToolSignatureCarrierDirection::Previous
} else {
GeminiToolSignatureCarrierDirection::Next
};
let Some(carrier) =
encode_gemini_tool_signature_carrier_with_direction(&signature, direction)
else {
return Ok(Vec::new());
};
if self
.tool_calls
.get(&index)
.and_then(|state| state.thought_signature_carrier.as_deref())
== Some(carrier.as_str())
{
return Ok(Vec::new());
}
let mut out = self.ensure_started()?;
let output_index = self.allocate_output_index();
let item = json!({
"type": "reasoning",
"id": openai_responses_synthetic_reasoning_item_id(
self.response_id(),
output_index,
),
"status": "completed",
"encrypted_content": carrier,
"summary": [],
});
let state = self.tool_calls.entry(index).or_default();
state.thought_signature_carrier = item
.get("encrypted_content")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
state.thought_signature_output_index = Some(output_index);
out.extend(self.encode_response_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"response_id": self.response_id(),
"output_index": output_index,
"item": item,
}),
)?);
out.extend(self.encode_response_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"response_id": self.response_id(),
"output_index": output_index,
"item": item,
}),
)?);
Ok(out)
}
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let mut out = self.ensure_started()?;
let output_index = self.ensure_tool_output_index(index);
@@ -3559,6 +3749,7 @@ fn openai_responses_incomplete_finish_reason(payload: &Value) -> String {
mod tests {
use super::*;
use crate::formats::claude::messages::stream::ClaudeClientEmitter;
use crate::formats::openai::responses::encode_gemini_tool_signature_carrier;
fn data_line(value: Value) -> Vec<u8> {
format!("data: {}\n", value).into_bytes()
@@ -4662,6 +4853,141 @@ mod tests {
assert!(!sse.contains("\\\"pages\\\":\\\"\\\""));
}
#[test]
fn openai_responses_provider_state_resolves_done_only_namespace_identity() {
let report_context = json!({
"original_request_body": {
"tools": [
{
"type": "function",
"name": "write_report",
"parameters": {"type": "object"}
},
{
"type": "namespace",
"name": "reports",
"description": "Reporting tools",
"tools": [{
"type": "function",
"name": "write_report",
"parameters": {"type": "object"}
}]
}
]
}
});
let expected_alias = NamespaceToolAliases::from_report_context(&report_context)
.chat_name("reports", "write_report")
.expect("namespace alias")
.to_string();
let mut state = OpenAIResponsesProviderState::default();
let frames = state
.push_line(
&report_context,
data_line(json!({
"type": "response.function_call_arguments.done",
"response_id": "resp_done_only_namespace",
"output_index": 0,
"item_id": "fc_done_only_namespace",
"call_id": "call_done_only_namespace",
"namespace": "reports",
"name": "write_report",
"arguments": "{}"
})),
)
.expect("done-only namespace call should parse");
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart {
ref call_id,
ref name,
..
} if call_id == "call_done_only_namespace" && name == &expected_alias
)));
}
#[test]
fn openai_responses_provider_state_keeps_namespace_alias_until_delayed_call_id() {
let report_context = json!({
"original_request_body": {
"tools": [
{
"type": "function",
"name": "write_report",
"parameters": {"type": "object"}
},
{
"type": "namespace",
"name": "reports",
"description": "Reporting tools",
"tools": [{
"type": "function",
"name": "write_report",
"parameters": {"type": "object"}
}]
}
]
}
});
let expected_alias = NamespaceToolAliases::from_report_context(&report_context)
.chat_name("reports", "write_report")
.expect("namespace alias")
.to_string();
let mut state = OpenAIResponsesProviderState::default();
let added = state
.push_line(
&report_context,
data_line(json!({
"type": "response.output_item.added",
"response_id": "resp_delayed_namespace",
"output_index": 0,
"item": {
"type": "function_call",
"id": "fc_delayed_namespace",
"namespace": "reports",
"name": "write_report",
"arguments": ""
}
})),
)
.expect("namespace item should parse");
assert!(!added
.iter()
.any(|frame| matches!(frame.event, CanonicalStreamEvent::ToolCallStart { .. })));
let done = state
.push_line(
&report_context,
data_line(json!({
"type": "response.function_call_arguments.done",
"response_id": "resp_delayed_namespace",
"output_index": 0,
"item_id": "fc_delayed_namespace",
"call_id": "call_delayed_namespace",
"name": "write_report",
"arguments": "{\"path\":\"reports/finding.md\"}"
})),
)
.expect("delayed namespace call identity should parse");
assert!(done.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart {
ref call_id,
ref name,
..
} if call_id == "call_delayed_namespace" && name == &expected_alias
)));
assert!(!done.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart { ref name, .. }
if name == "write_report" && name != &expected_alias
)));
}
#[test]
fn openai_responses_provider_state_waits_for_call_id_distinct_from_item_id() {
let mut state = OpenAIResponsesProviderState::default();
@@ -5091,6 +5417,124 @@ mod tests {
);
}
#[test]
fn openai_responses_client_emitter_carries_gemini_tool_signature() {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "resp_signed_tool_123".to_string(),
model: "gemini-3-flash-preview".to_string(),
event: CanonicalStreamEvent::ToolCallSignature {
index: 0,
signature: "opaque-tool-signature".to_string(),
},
})
.expect("tool signature should encode");
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_signed_tool_123".to_string(),
model: "gemini-3-flash-preview".to_string(),
event: CanonicalStreamEvent::ToolCallStart {
index: 0,
call_id: "call_123".to_string(),
name: "lookup".to_string(),
},
})
.expect("tool call should encode"),
);
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_signed_tool_123".to_string(),
model: "gemini-3-flash-preview".to_string(),
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index: 0,
arguments: "{\"query\":\"rust\"}".to_string(),
},
})
.expect("tool arguments should encode"),
);
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_signed_tool_123".to_string(),
model: "gemini-3-flash-preview".to_string(),
event: CanonicalStreamEvent::Finish {
finish_reason: Some("tool_calls".to_string()),
usage: None,
},
})
.expect("tool finish should encode"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
let carrier = encode_gemini_tool_signature_carrier("opaque-tool-signature")
.expect("signature carrier");
let carrier_index = sse.find(&carrier).expect("carrier in Responses stream");
let call_index = sse
.find("\"call_id\":\"call_123\"")
.expect("function call in Responses stream");
assert!(carrier_index < call_index);
assert!(sse.contains("\"encrypted_content\""));
assert!(sse.contains("event: response.completed\n"));
}
#[test]
fn openai_responses_client_emitter_carries_late_gemini_tool_signature() {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "resp_late_signed_tool_123".to_string(),
model: "gemini-3-flash-preview".to_string(),
event: CanonicalStreamEvent::ToolCallStart {
index: 0,
call_id: "call_123".to_string(),
name: "lookup".to_string(),
},
})
.expect("tool call should encode");
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_late_signed_tool_123".to_string(),
model: "gemini-3-flash-preview".to_string(),
event: CanonicalStreamEvent::ToolCallSignature {
index: 0,
signature: "opaque-late-tool-signature".to_string(),
},
})
.expect("late tool signature should encode"),
);
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_late_signed_tool_123".to_string(),
model: "gemini-3-flash-preview".to_string(),
event: CanonicalStreamEvent::Finish {
finish_reason: Some("tool_calls".to_string()),
usage: None,
},
})
.expect("tool finish should encode"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
let carrier = encode_gemini_tool_signature_carrier_with_direction(
"opaque-late-tool-signature",
GeminiToolSignatureCarrierDirection::Previous,
)
.expect("late signature carrier");
let call_index = sse
.find("\"call_id\":\"call_123\"")
.expect("function call in Responses stream");
let carrier_index = sse
.find(&carrier)
.expect("late carrier in Responses stream");
assert!(call_index < carrier_index);
assert!(sse.contains("event: response.completed\n"));
}
#[test]
fn openai_responses_client_emitter_emits_web_search_call_item() {
let mut emitter = OpenAIResponsesClientEmitter::default();
@@ -1,6 +1,7 @@
pub mod chat;
pub mod embedding;
pub mod image;
pub(crate) mod namespace;
pub mod prompt_cache;
pub mod reasoning;
pub mod request_contract;
@@ -0,0 +1,605 @@
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
use crate::protocol::canonical::{
openai_responses_tools_to_canonical, CanonicalToolDefinition,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
};
const OPENAI_CHAT_TOOL_NAME_MAX_LEN: usize = 64;
const HASHED_ALIAS_PREFIX: &str = "aether_ns_";
const HASH_HEX_LEN: usize = 32;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct NamespaceChatTool {
pub source_tool_index: usize,
pub source_child_index: usize,
pub namespace: String,
pub name: String,
pub chat_name: String,
pub description: Option<String>,
pub parameters: Option<Value>,
pub strict: Option<Value>,
}
impl NamespaceChatTool {
pub(crate) fn to_openai_chat_tool(&self) -> Value {
let mut function = Map::new();
function.insert("name".to_string(), Value::String(self.chat_name.clone()));
if let Some(description) = &self.description {
function.insert(
"description".to_string(),
Value::String(description.clone()),
);
}
if let Some(parameters) = &self.parameters {
function.insert("parameters".to_string(), parameters.clone());
}
if let Some(strict) = &self.strict {
function.insert("strict".to_string(), strict.clone());
}
json!({
"type": "function",
"function": Value::Object(function),
})
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub(crate) struct NamespaceToolAliases {
tools: Vec<NamespaceChatTool>,
by_identity: BTreeMap<(String, String), String>,
by_chat_name: BTreeMap<String, (String, String)>,
namespace_tool_indices: BTreeSet<usize>,
invalid_namespace_tool_indices: BTreeSet<usize>,
}
impl NamespaceToolAliases {
pub(crate) fn from_canonical_tools(tools: &[CanonicalToolDefinition]) -> Self {
let mut result = Self::default();
let mut parsed = Vec::new();
let mut name_counts = BTreeMap::<String, usize>::new();
let mut ordinary_chat_names = BTreeSet::<String>::new();
for (tool_index, tool) in tools.iter().enumerate() {
if canonical_tool_is_responses_namespace(tool) {
result.namespace_tool_indices.insert(tool_index);
match parse_namespace_tool(tool_index, tool) {
Some(children) => {
for child in &children {
*name_counts.entry(child.name.clone()).or_default() += 1;
}
parsed.extend(children);
}
None => {
result.invalid_namespace_tool_indices.insert(tool_index);
}
}
} else {
*name_counts.entry(tool.name.clone()).or_default() += 1;
ordinary_chat_names.insert(tool.name.clone());
}
}
let mut sources_by_identity = BTreeMap::<(String, String), BTreeSet<usize>>::new();
for child in &parsed {
let identity = (child.namespace.clone(), child.name.clone());
sources_by_identity
.entry(identity)
.or_default()
.insert(child.source_tool_index);
}
for sources in sources_by_identity
.values()
.filter(|sources| sources.len() > 1)
{
result
.invalid_namespace_tool_indices
.extend(sources.iter().copied());
}
parsed.retain(|child| {
!result
.invalid_namespace_tool_indices
.contains(&child.source_tool_index)
});
let preferred_names = parsed
.iter()
.map(|child| {
let name_is_globally_unique = name_counts.get(&child.name).copied() == Some(1);
let preferred = if name_is_globally_unique
&& is_valid_chat_tool_name(&child.name)
&& !ordinary_chat_names.contains(&child.name)
{
Some(child.name.clone())
} else {
let readable = format!("{}__{}", child.namespace, child.name);
(is_valid_chat_tool_name(&readable) && !ordinary_chat_names.contains(&readable))
.then_some(readable)
};
((child.namespace.clone(), child.name.clone()), preferred)
})
.collect::<BTreeMap<_, _>>();
let mut preferred_counts = BTreeMap::<String, usize>::new();
for preferred in preferred_names.values().flatten() {
*preferred_counts.entry(preferred.clone()).or_default() += 1;
}
let mut aliases_by_identity = BTreeMap::<(String, String), String>::new();
let mut used_chat_names = ordinary_chat_names;
for (identity, preferred) in &preferred_names {
if let Some(preferred) = preferred
.as_ref()
.filter(|name| preferred_counts.get(*name).copied() == Some(1))
{
aliases_by_identity.insert(identity.clone(), preferred.clone());
used_chat_names.insert(preferred.clone());
}
}
for identity in preferred_names.keys() {
if aliases_by_identity.contains_key(identity) {
continue;
}
let chat_name = allocate_hashed_alias(&identity.0, &identity.1, &used_chat_names);
used_chat_names.insert(chat_name.clone());
aliases_by_identity.insert(identity.clone(), chat_name);
}
for mut child in parsed {
let identity = (child.namespace.clone(), child.name.clone());
let chat_name = aliases_by_identity
.get(&identity)
.expect("every valid namespace child receives an alias")
.clone();
child.chat_name = chat_name.clone();
result
.by_identity
.insert(identity.clone(), chat_name.clone());
result.by_chat_name.insert(chat_name, identity);
result.tools.push(child);
}
result
}
pub(crate) fn from_report_context(report_context: &Value) -> Self {
let Some(tools) = report_context
.get("original_request_body")
.and_then(|request| request.get("tools"))
else {
return Self::default();
};
let Some(canonical) = openai_responses_tools_to_canonical(Some(tools)) else {
return Self::default();
};
Self::from_canonical_tools(&canonical)
}
pub(crate) fn chat_name(&self, namespace: &str, child_name: &str) -> Option<&str> {
self.by_identity
.get(&(namespace.to_string(), child_name.to_string()))
.map(String::as_str)
}
pub(crate) fn responses_name(&self, chat_name: &str) -> Option<(&str, &str)> {
self.by_chat_name
.get(chat_name)
.map(|(namespace, child_name)| (namespace.as_str(), child_name.as_str()))
}
pub(crate) fn tools_for_source(
&self,
source_tool_index: usize,
) -> impl Iterator<Item = &NamespaceChatTool> {
self.tools
.iter()
.filter(move |tool| tool.source_tool_index == source_tool_index)
}
pub(crate) fn is_namespace_tool(&self, source_tool_index: usize) -> bool {
self.namespace_tool_indices.contains(&source_tool_index)
}
pub(crate) fn is_representable_namespace_tool(&self, source_tool_index: usize) -> bool {
self.is_namespace_tool(source_tool_index)
&& !self
.invalid_namespace_tool_indices
.contains(&source_tool_index)
&& self
.tools
.iter()
.any(|tool| tool.source_tool_index == source_tool_index)
}
pub(crate) fn has_invalid_namespace_tools(&self) -> bool {
!self.invalid_namespace_tool_indices.is_empty()
}
pub(crate) fn namespace_children_named<'a>(
&'a self,
child_name: &'a str,
) -> impl Iterator<Item = &'a NamespaceChatTool> + 'a {
self.tools
.iter()
.filter(move |tool| tool.name == child_name)
}
}
pub(crate) fn canonical_tool_is_responses_namespace(tool: &CanonicalToolDefinition) -> bool {
raw_responses_tool(tool).is_some_and(|raw| {
raw.get("type")
.and_then(Value::as_str)
.is_some_and(|tool_type| tool_type.eq_ignore_ascii_case("namespace"))
})
}
fn raw_responses_tool(tool: &CanonicalToolDefinition) -> Option<&Map<String, Value>> {
tool.extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.or_else(|| {
tool.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.and_then(Value::as_object)
}
fn parse_namespace_tool(
source_tool_index: usize,
tool: &CanonicalToolDefinition,
) -> Option<Vec<NamespaceChatTool>> {
let raw = raw_responses_tool(tool)?;
if !object_has_only_keys(raw, &["type", "name", "description", "tools"]) {
return None;
}
let namespace = non_empty_string(raw.get("name"))?.to_string();
if !matches!(raw.get("description"), Some(Value::String(_))) {
return None;
}
let children = raw.get("tools")?.as_array()?;
if children.is_empty() {
return None;
}
let mut names = BTreeSet::new();
let mut parsed = Vec::with_capacity(children.len());
for (source_child_index, child) in children.iter().enumerate() {
let child = child.as_object()?;
if child.get("type").and_then(Value::as_str) != Some("function") {
return None;
}
if !object_has_only_keys(
child,
&["type", "name", "description", "parameters", "strict"],
) {
return None;
}
let function = child;
let name = non_empty_string(function.get("name"))?.to_string();
if !names.insert(name.clone()) {
return None;
}
// Responses permits an omitted or null parameter schema, while Chat
// Completions only permits an omitted schema or an object. Treat null
// as the omitted form instead of forwarding an invalid
// `parameters: null` Chat tool definition.
let parameters = match function.get("parameters") {
Some(parameters @ Value::Object(_)) => Some(parameters.clone()),
Some(Value::Null) | None => None,
Some(_) => return None,
};
let strict = match function.get("strict") {
Some(strict @ (Value::Bool(_) | Value::Null)) => Some(strict.clone()),
Some(_) => return None,
None => None,
};
let description = match function.get("description") {
Some(Value::String(description)) => Some(description.clone()),
Some(Value::Null) | None => None,
Some(_) => return None,
};
parsed.push(NamespaceChatTool {
source_tool_index,
source_child_index,
namespace: namespace.clone(),
name,
chat_name: String::new(),
description,
parameters,
strict,
});
}
Some(parsed)
}
fn object_has_only_keys(object: &Map<String, Value>, allowed: &[&str]) -> bool {
object.keys().all(|key| allowed.contains(&key.as_str()))
}
fn non_empty_string(value: Option<&Value>) -> Option<&str> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn is_valid_chat_tool_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= OPENAI_CHAT_TOOL_NAME_MAX_LEN
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
}
fn allocate_hashed_alias(namespace: &str, child_name: &str, used: &BTreeSet<String>) -> String {
for nonce in 0_u64.. {
let candidate = hashed_alias(namespace, child_name, nonce);
if !used.contains(&candidate) {
return candidate;
}
}
unreachable!("u64 alias nonce space cannot be exhausted")
}
fn sanitize_chat_name_component(value: &str) -> String {
value
.bytes()
.map(|byte| {
if byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-') {
char::from(byte)
} else {
'_'
}
})
.collect()
}
fn hashed_alias(namespace: &str, child_name: &str, nonce: u64) -> String {
let mut hasher = Sha256::new();
hasher.update(b"aether-openai-namespace-tool\0");
hasher.update(namespace.as_bytes());
hasher.update(b"\0");
hasher.update(child_name.as_bytes());
hasher.update(b"\0");
hasher.update(nonce.to_le_bytes());
let digest = hasher.finalize();
let digest_hex = digest
.iter()
.take(HASH_HEX_LEN / 2)
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let semantic_budget =
OPENAI_CHAT_TOOL_NAME_MAX_LEN - HASHED_ALIAS_PREFIX.len() - 1 - digest_hex.len();
let mut semantic = sanitize_chat_name_component(child_name);
semantic.truncate(semantic_budget);
if semantic.is_empty() {
semantic.push_str("tool");
semantic.truncate(semantic_budget);
}
format!("{HASHED_ALIAS_PREFIX}{semantic}_{digest_hex}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::canonical::openai_responses_tools_to_canonical;
fn aliases(tools: Value) -> NamespaceToolAliases {
let canonical = openai_responses_tools_to_canonical(Some(&tools))
.expect("Responses tools should parse");
NamespaceToolAliases::from_canonical_tools(&canonical)
}
#[test]
fn namespace_aliases_keep_unique_child_names_and_reverse_them() {
let aliases = aliases(json!([{
"type": "namespace",
"name": "mcp__reports",
"description": "Reporting tools",
"tools": [{
"type": "function",
"name": "vulnerability_report",
"parameters": {"type": "object", "properties": {}}
}]
}]));
assert_eq!(
aliases.chat_name("mcp__reports", "vulnerability_report"),
Some("vulnerability_report")
);
assert_eq!(
aliases.responses_name("vulnerability_report"),
Some(("mcp__reports", "vulnerability_report"))
);
}
#[test]
fn namespace_aliases_are_unique_bounded_and_prefix_safe() {
let long_namespace = format!("namespace__{}", "n".repeat(120));
let long_child = format!("aether_ns__{}", "c".repeat(120));
let tools = json!([
{
"type": "function",
"name": "shared",
"parameters": {"type": "object"}
},
{
"type": "namespace",
"name": "first__namespace",
"description": "First namespace",
"tools": [{
"type": "function",
"name": "shared",
"parameters": {"type": "object"}
}]
},
{
"type": "namespace",
"name": "second__namespace",
"description": "Second namespace",
"tools": [{
"type": "function",
"name": "shared",
"parameters": {"type": "object"}
}]
},
{
"type": "namespace",
"name": long_namespace,
"description": "Long namespace",
"tools": [{
"type": "function",
"name": long_child,
"parameters": {"type": "object"}
}]
}
]);
let aliases = aliases(tools);
let names = aliases
.tools
.iter()
.map(|tool| tool.chat_name.as_str())
.collect::<BTreeSet<_>>();
assert_eq!(names.len(), 3);
assert!(!names.contains("shared"));
assert!(names.iter().all(|name| is_valid_chat_tool_name(name)));
assert!(names.iter().all(|name| name.len() <= 64));
for tool in &aliases.tools {
assert_eq!(
aliases.responses_name(&tool.chat_name),
Some((tool.namespace.as_str(), tool.name.as_str()))
);
}
}
#[test]
fn namespace_aliases_are_stable_across_tool_order_and_readable_collisions() {
let first = json!([
{
"type": "function",
"name": "b__c",
"parameters": {"type": "object"}
},
{
"type": "function",
"name": "c",
"parameters": {"type": "object"}
},
{
"type": "namespace",
"name": "a",
"description": "A",
"tools": [{
"type": "function",
"name": "b__c",
"parameters": {"type": "object"}
}]
},
{
"type": "namespace",
"name": "a__b",
"description": "AB",
"tools": [{
"type": "function",
"name": "c",
"parameters": {"type": "object"}
}]
}
]);
let second = json!([
first[3].clone(),
first[2].clone(),
first[1].clone(),
first[0].clone()
]);
let first = aliases(first);
let second = aliases(second);
for identity in [("a", "b__c"), ("a__b", "c")] {
let first_alias = first
.chat_name(identity.0, identity.1)
.expect("first alias");
let second_alias = second
.chat_name(identity.0, identity.1)
.expect("second alias");
assert_eq!(first_alias, second_alias);
assert!(first_alias.starts_with(HASHED_ALIAS_PREFIX));
}
}
#[test]
fn namespace_children_preserve_nullable_fields_without_inheriting_parent_description() {
let aliases = aliases(json!([{
"type": "namespace",
"name": "reports",
"description": "Parent description",
"tools": [{
"type": "function",
"name": "write_report",
"description": null,
"parameters": null,
"strict": null
}]
}]));
let chat_tool = aliases
.tools_for_source(0)
.next()
.expect("namespace child")
.to_openai_chat_tool();
assert!(chat_tool["function"].get("description").is_none());
assert!(chat_tool["function"].get("parameters").is_none());
assert_eq!(chat_tool["function"]["strict"], Value::Null);
}
#[test]
fn malformed_namespace_is_not_representable() {
for raw in [
json!({"type": "namespace", "name": "broken"}),
json!({
"type": "namespace",
"name": "broken",
"tools": [{"type": "function", "name": "missing_parent_description"}]
}),
json!({
"type": "namespace",
"name": "broken",
"description": "Broken",
"tools": {}
}),
json!({
"type": "namespace",
"name": "broken",
"description": "Broken",
"tools": [{"type": "function"}]
}),
json!({
"type": "namespace",
"name": "broken",
"description": "Broken",
"tools": [{"type": "custom", "name": "raw"}]
}),
json!({
"type": "namespace",
"name": "broken",
"description": "Broken",
"tools": [{"name": "missing_type"}]
}),
json!({
"type": "namespace",
"name": "broken",
"description": "Broken",
"tools": [{
"type": "function",
"function": {"name": "nested"}
}]
}),
] {
let aliases = aliases(json!([raw]));
assert!(aliases.has_invalid_namespace_tools());
assert!(!aliases.is_representable_namespace_tool(0));
}
}
}
@@ -851,10 +851,26 @@ fn header_value_contains_media_type(value: &str, media_type: &str) -> bool {
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CodexAuthIdentity {
pub account_id: Option<String>,
pub account_user_id: Option<String>,
pub user_id: Option<String>,
pub email: Option<String>,
pub codex_identity_fingerprint: Option<String>,
pub is_fedramp: bool,
pub uses_codex_backend: bool,
}
fn first_non_empty_codex_identity_string<'a>(
values: impl IntoIterator<Item = Option<&'a Value>>,
) -> Option<String> {
values.into_iter().find_map(|value| {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
pub fn parse_codex_auth_identity(decrypted_auth_config_raw: Option<&str>) -> CodexAuthIdentity {
let Some(raw) = decrypted_auth_config_raw
.map(str::trim)
@@ -868,29 +884,72 @@ pub fn parse_codex_auth_identity(decrypted_auth_config_raw: Option<&str>) -> Cod
let namespaced_auth = value
.get("https://api.openai.com/auth")
.and_then(Value::as_object);
let namespaced_profile = value
.get("https://api.openai.com/profile")
.and_then(Value::as_object);
let agent_identity = value
.get("agent_identity")
.or_else(|| value.get("agentIdentity"))
.and_then(Value::as_object);
let account_id = value
.get("account_id")
.or_else(|| value.get("accountId"))
.or_else(|| value.get("chatgpt_account_id"))
.or_else(|| value.get("chatgptAccountId"))
.or_else(|| namespaced_auth.and_then(|auth| auth.get("chatgpt_account_id")))
.or_else(|| {
agent_identity.and_then(|identity| {
identity
.get("account_id")
.or_else(|| identity.get("accountId"))
.or_else(|| identity.get("chatgpt_account_id"))
.or_else(|| identity.get("chatgptAccountId"))
})
})
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let account_id = first_non_empty_codex_identity_string([
value.get("account_id"),
value.get("accountId"),
value.get("chatgpt_account_id"),
value.get("chatgptAccountId"),
namespaced_auth.and_then(|auth| auth.get("chatgpt_account_id")),
agent_identity.and_then(|identity| identity.get("account_id")),
agent_identity.and_then(|identity| identity.get("accountId")),
agent_identity.and_then(|identity| identity.get("chatgpt_account_id")),
agent_identity.and_then(|identity| identity.get("chatgptAccountId")),
]);
let account_user_id = first_non_empty_codex_identity_string([
value.get("account_user_id"),
value.get("accountUserId"),
value.get("chatgpt_account_user_id"),
value.get("chatgptAccountUserId"),
namespaced_auth.and_then(|auth| auth.get("chatgpt_account_user_id")),
agent_identity.and_then(|identity| identity.get("account_user_id")),
agent_identity.and_then(|identity| identity.get("accountUserId")),
agent_identity.and_then(|identity| identity.get("chatgpt_account_user_id")),
agent_identity.and_then(|identity| identity.get("chatgptAccountUserId")),
]);
let user_id = first_non_empty_codex_identity_string([
value.get("user_id"),
value.get("userId"),
value.get("chatgpt_user_id"),
value.get("chatgptUserId"),
namespaced_auth.and_then(|auth| auth.get("chatgpt_user_id")),
agent_identity.and_then(|identity| identity.get("user_id")),
agent_identity.and_then(|identity| identity.get("userId")),
agent_identity.and_then(|identity| identity.get("chatgpt_user_id")),
agent_identity.and_then(|identity| identity.get("chatgptUserId")),
value.get("sub"),
]);
let email = first_non_empty_codex_identity_string([
value.get("email"),
value.get("email_address"),
value.get("emailAddress"),
value.get("outlook_email"),
namespaced_auth.and_then(|auth| auth.get("email")),
namespaced_auth.and_then(|auth| auth.get("email_address")),
namespaced_auth.and_then(|auth| auth.get("emailAddress")),
namespaced_auth.and_then(|auth| auth.get("outlook_email")),
namespaced_profile.and_then(|profile| profile.get("email")),
namespaced_profile.and_then(|profile| profile.get("email_address")),
namespaced_profile.and_then(|profile| profile.get("emailAddress")),
agent_identity.and_then(|identity| identity.get("email")),
agent_identity.and_then(|identity| identity.get("email_address")),
agent_identity.and_then(|identity| identity.get("emailAddress")),
agent_identity.and_then(|identity| identity.get("outlook_email")),
]);
let codex_identity_fingerprint = first_non_empty_codex_identity_string([
value.get("codex_identity_fingerprint"),
value.get("codex-identity-fingerprint"),
value.get("codexIdentityFingerprint"),
agent_identity.and_then(|identity| identity.get("codex_identity_fingerprint")),
agent_identity.and_then(|identity| identity.get("codex-identity-fingerprint")),
agent_identity.and_then(|identity| identity.get("codexIdentityFingerprint")),
]);
let is_fedramp = value
.get("is_fedramp")
.or_else(|| value.get("chatgpt_account_is_fedramp"))
@@ -907,6 +966,9 @@ pub fn parse_codex_auth_identity(decrypted_auth_config_raw: Option<&str>) -> Cod
.and_then(Value::as_bool)
.unwrap_or(false);
let uses_codex_backend = account_id.is_some()
|| account_user_id.is_some()
|| user_id.is_some()
|| codex_identity_fingerprint.is_some()
|| value
.get("provider_type")
.and_then(Value::as_str)
@@ -919,6 +981,10 @@ pub fn parse_codex_auth_identity(decrypted_auth_config_raw: Option<&str>) -> Cod
CodexAuthIdentity {
account_id,
account_user_id,
user_id,
email,
codex_identity_fingerprint,
is_fedramp,
uses_codex_backend,
}
@@ -2125,7 +2191,8 @@ mod tests {
apply_codex_openai_responses_websocket_continuation_body_edits_with_source_model_and_capabilities,
apply_codex_openai_special_headers, apply_openai_responses_compact_special_body_edits,
build_codex_model_catalog_metadata, bundled_codex_model_cards, effective_codex_model_cards,
project_codex_catalog_model_card, resolve_codex_responses_model_capabilities,
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_OPENAI_IMAGE_INTERNAL_MODEL,
CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS, CODEX_RESPONSES_LITE_HEADER,
@@ -2778,6 +2845,41 @@ mod tests {
assert!(capabilities.supported_service_tiers.is_empty());
}
#[test]
fn codex_auth_identity_parses_member_claims_and_persisted_fingerprint() {
let identity = parse_codex_auth_identity(Some(
&json!({
"provider_type": "codex",
"accountId": "workspace-1",
"codexIdentityFingerprint": "codex-persisted-fingerprint:v1:stable",
"https://api.openai.com/auth": {
"chatgpt_account_user_id": "workspace-member-1",
"chatgpt_user_id": "user-1"
},
"https://api.openai.com/profile": {
"email": "[email protected]"
}
})
.to_string(),
));
assert_eq!(identity.account_id.as_deref(), Some("workspace-1"));
assert_eq!(
identity.account_user_id.as_deref(),
Some("workspace-member-1")
);
assert_eq!(identity.user_id.as_deref(), Some("user-1"));
assert_eq!(identity.email.as_deref(), Some("[email protected]"));
assert_eq!(
identity.codex_identity_fingerprint.as_deref(),
Some("codex-persisted-fingerprint:v1:stable")
);
assert!(identity.uses_codex_backend);
let sub_fallback = parse_codex_auth_identity(Some(r#"{"sub":"legacy-user-1"}"#));
assert_eq!(sub_fallback.user_id.as_deref(), Some("legacy-user-1"));
}
#[test]
fn codex_identity_headers_are_derived_only_from_auth_config() {
let mut headers = std::collections::BTreeMap::from([
@@ -1,3 +1,4 @@
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
use serde_json::Value;
pub mod codex;
@@ -10,6 +11,69 @@ pub mod stream;
const TOOL_ERROR_PREFIX: &str = "[tool error]";
const AETHER_REASONING_ITEM_ID_PREFIX: &str = "rs_aether_";
const AETHER_MESSAGE_ITEM_ID_PREFIX: &str = "msg_aether_";
const GEMINI_TOOL_SIGNATURE_CARRIER_PREFIX: &str = "cpa-gemini-responses-carrier-v1:";
const MAX_GEMINI_THOUGHT_SIGNATURE_LEN: usize = 32 * 1024 * 1024;
const MAX_GEMINI_THOUGHT_SIGNATURE_ENCODED_LEN: usize =
MAX_GEMINI_THOUGHT_SIGNATURE_LEN.div_ceil(3) * 4;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GeminiToolSignatureCarrierDirection {
Next,
Previous,
}
impl GeminiToolSignatureCarrierDirection {
fn as_str(self) -> &'static str {
match self {
Self::Next => "next",
Self::Previous => "previous",
}
}
}
pub(crate) fn encode_gemini_tool_signature_carrier(signature: &str) -> Option<String> {
encode_gemini_tool_signature_carrier_with_direction(
signature,
GeminiToolSignatureCarrierDirection::Next,
)
}
pub(crate) fn encode_gemini_tool_signature_carrier_with_direction(
signature: &str,
direction: GeminiToolSignatureCarrierDirection,
) -> Option<String> {
(!signature.trim().is_empty() && signature.len() <= MAX_GEMINI_THOUGHT_SIGNATURE_LEN).then(
|| {
format!(
"{GEMINI_TOOL_SIGNATURE_CARRIER_PREFIX}{}:function:{}",
direction.as_str(),
STANDARD_NO_PAD.encode(signature)
)
},
)
}
pub(crate) fn decode_gemini_tool_signature_carrier(
carrier: &str,
) -> Option<(String, GeminiToolSignatureCarrierDirection)> {
let payload = carrier.strip_prefix(GEMINI_TOOL_SIGNATURE_CARRIER_PREFIX)?;
let (direction, encoded) = payload.split_once(":function:")?;
let direction = match direction {
"next" => GeminiToolSignatureCarrierDirection::Next,
"previous" => GeminiToolSignatureCarrierDirection::Previous,
_ => return None,
};
if encoded.len() > MAX_GEMINI_THOUGHT_SIGNATURE_ENCODED_LEN {
return None;
}
let decoded = STANDARD_NO_PAD.decode(encoded).ok()?;
if decoded.len() > MAX_GEMINI_THOUGHT_SIGNATURE_LEN {
return None;
}
let signature = String::from_utf8(decoded).ok()?;
(!signature.trim().is_empty() && !signature.starts_with(GEMINI_TOOL_SIGNATURE_CARRIER_PREFIX))
.then_some((signature, direction))
}
/// Controls which provider-owned reasoning items may be replayed on a Responses request.
///
@@ -252,14 +316,60 @@ mod tests {
use serde_json::json;
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,
strip_incompatible_openai_responses_reasoning_items,
strip_incompatible_openai_responses_reasoning_items_with_policy,
OpenAiResponsesReasoningReplayPolicy, OPENAI_RESPONSES_OPERATION_COMPACT,
GeminiToolSignatureCarrierDirection, OpenAiResponsesReasoningReplayPolicy,
MAX_GEMINI_THOUGHT_SIGNATURE_ENCODED_LEN, MAX_GEMINI_THOUGHT_SIGNATURE_LEN,
OPENAI_RESPONSES_OPERATION_COMPACT,
};
#[test]
fn gemini_tool_signature_carrier_roundtrips_direction_and_exact_value() {
let signature = " opaque-signature-with-padding== ";
for direction in [
GeminiToolSignatureCarrierDirection::Next,
GeminiToolSignatureCarrierDirection::Previous,
] {
let carrier = encode_gemini_tool_signature_carrier_with_direction(signature, direction)
.expect("signature carrier");
assert_eq!(
decode_gemini_tool_signature_carrier(&carrier),
Some((signature.to_string(), direction))
);
}
}
#[test]
fn gemini_tool_signature_carrier_rejects_nested_and_oversized_values() {
let nested = encode_gemini_tool_signature_carrier_with_direction(
"opaque-signature",
GeminiToolSignatureCarrierDirection::Next,
)
.expect("inner carrier");
let nested = encode_gemini_tool_signature_carrier_with_direction(
&nested,
GeminiToolSignatureCarrierDirection::Previous,
)
.expect("outer carrier");
assert_eq!(decode_gemini_tool_signature_carrier(&nested), None);
assert_eq!(
encode_gemini_tool_signature_carrier_with_direction(
&"x".repeat(MAX_GEMINI_THOUGHT_SIGNATURE_LEN + 1),
GeminiToolSignatureCarrierDirection::Next,
),
None
);
let oversized = format!(
"cpa-gemini-responses-carrier-v1:next:function:{}",
"A".repeat(MAX_GEMINI_THOUGHT_SIGNATURE_ENCODED_LEN + 1)
);
assert_eq!(decode_gemini_tool_signature_carrier(&oversized), None);
}
#[test]
fn resolves_compaction_trigger_as_compact_operation_on_responses_transport() {
assert_eq!(
@@ -6,12 +6,12 @@ use std::{
use serde_json::{json, Map, Value};
use super::{
encode_tool_result_error, history::record_converted_response_history,
openai_responses_synthetic_reasoning_item_id,
encode_gemini_tool_signature_carrier, encode_tool_result_error,
history::record_converted_response_history, openai_responses_synthetic_reasoning_item_id,
};
use crate::{
formats::context::FormatContext,
formats::{context::FormatContext, openai::namespace::NamespaceToolAliases},
protocol::canonical::{
canonical_content_block_to_openai_responses_part, canonical_extension_object_mut,
canonical_tool_use_to_openai_responses_item, canonical_usage_to_openai_responses_usage,
@@ -114,6 +114,7 @@ fn openai_responses_incomplete_stop_reason(body: &Map<String, Value>) -> Canonic
}
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();
let response_id = canonical.id.replace("chatcmpl", "resp");
response.insert("id".to_string(), Value::String(response_id.clone()));
@@ -239,7 +240,30 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo
&response_id,
&mut message_index,
);
if is_responses_web_search_tool(name) {
if let Some(signature) = extensions
.get("gemini")
.and_then(Value::as_object)
.and_then(|gemini| {
gemini
.get("thoughtSignature")
.or_else(|| gemini.get("thought_signature"))
})
.and_then(Value::as_str)
.and_then(encode_gemini_tool_signature_carrier)
{
output.push(json!({
"type": "reasoning",
"id": openai_responses_synthetic_reasoning_item_id(
&response_id,
output.len(),
),
"status": "completed",
"encrypted_content": signature,
"summary": [],
}));
}
let namespaced_tool = namespace_tool_aliases.responses_name(name);
if namespaced_tool.is_none() && is_responses_web_search_tool(name) {
output.push(json!({
"type": "web_search_call",
"id": id,
@@ -250,9 +274,24 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo
},
}));
} else {
output.push(canonical_tool_use_to_openai_responses_item(
id, name, input, extensions,
));
let response_name = namespaced_tool
.map(|(_, child_name)| child_name)
.unwrap_or(name.as_str());
let mut item = canonical_tool_use_to_openai_responses_item(
id,
response_name,
input,
extensions,
);
if let Some((namespace, _)) = namespaced_tool {
if let Some(item) = item.as_object_mut() {
item.insert(
"namespace".to_string(),
Value::String(namespace.to_string()),
);
}
}
output.push(item);
}
}
CanonicalContentBlock::ToolResult {
@@ -596,6 +635,66 @@ mod tests {
assert!(body["completed_at"].as_i64().is_some());
}
#[test]
fn responses_response_builder_restores_namespaced_chat_tool_identity() {
let report_context = json!({
"original_request_body": {
"tools": [
{
"type": "function",
"name": "vulnerability_report",
"description": "ordinary function",
"parameters": {"type": "object", "properties": {}}
},
{
"type": "namespace",
"name": "mcp__vulnerability_report",
"description": "reporting tools",
"tools": [{
"type": "function",
"name": "vulnerability_report",
"description": "write the confirmed report",
"parameters": {
"type": "object",
"properties": {"report_path": {"type": "string"}},
"required": ["report_path"]
},
"strict": true
}]
}
]
}
});
let aliases = NamespaceToolAliases::from_report_context(&report_context);
let chat_name = aliases
.chat_name("mcp__vulnerability_report", "vulnerability_report")
.expect("namespace child should have a Chat alias")
.to_string();
assert_ne!(chat_name, "vulnerability_report");
let response = CanonicalResponse {
id: "chatcmpl_namespace".to_string(),
model: "qwen".to_string(),
content: vec![CanonicalContentBlock::ToolUse {
id: "call_report_1".to_string(),
name: chat_name,
input: json!({"report_path": "reports/sql-001-c1.md"}),
extensions: BTreeMap::new(),
}],
outputs: Vec::new(),
stop_reason: Some(CanonicalStopReason::ToolUse),
usage: None,
extensions: BTreeMap::new(),
};
let body = to_raw(&response, &report_context, false);
let item = &body["output"][0];
assert_eq!(item["type"], "function_call");
assert_eq!(item["name"], "vulnerability_report");
assert_eq!(item["namespace"], "mcp__vulnerability_report");
assert_eq!(item["call_id"], "call_report_1");
}
#[test]
fn responses_response_builder_emits_modern_output_text_and_preserves_source_fields() {
let mut extensions = BTreeMap::new();
File diff suppressed because it is too large Load Diff
@@ -641,6 +641,10 @@ pub fn claude_model_uses_adaptive_effort(model: &str) -> bool {
}
pub fn gemini_model_uses_thinking_level(model: &str) -> bool {
gemini_model_supports_mixed_tools(model)
}
pub(crate) fn gemini_model_supports_mixed_tools(model: &str) -> bool {
model
.trim()
.to_ascii_lowercase()
@@ -1410,6 +1410,14 @@ mod tests {
assert!(!input_json.contains("sig_123"));
continue;
}
if provider_api_format == "gemini:generate_content" {
assert_eq!(
converted["contents"][2]["parts"][0]["functionResponse"]["response"]
["result"],
json!({"ok": true})
);
continue;
}
let legacy =
legacy_claude_request_body(&request, provider_api_format, upstream_is_stream);
assert_eq!(
@@ -1775,7 +1783,7 @@ mod tests {
let converted = build_standard_request_body(
&request,
"claude:messages",
"gemini-2.5-pro",
"gemini-3-flash-preview",
"google",
"gemini:generate_content",
"/v1/messages",
@@ -2021,4 +2029,52 @@ mod tests {
"surface conversion should preserve the Claude tool schema before transport envelopes"
);
}
#[test]
fn openai_responses_builtin_and_function_tools_enable_gemini_server_invocations() {
let request = json!({
"model": "gpt-5",
"input": "Search first, then save the result.",
"tools": [
{"type": "web_search_preview"},
{
"type": "function",
"name": "save_result",
"description": "Save a search result",
"parameters": {
"type": "object",
"properties": {
"result": {"type": "string"}
},
"required": ["result"]
}
}
],
"tool_choice": "required"
});
let gemini = build_standard_request_body(
&request,
"openai:responses",
"gemini-3-flash-preview",
"google",
"gemini:generate_content",
"/v1/responses",
true,
None,
None,
)
.expect("openai responses should convert to gemini generate content");
assert_eq!(gemini["tools"][0]["googleSearch"], json!({}));
assert_eq!(
gemini["tools"][1]["functionDeclarations"][0]["name"],
"save_result"
);
assert_eq!(
gemini["toolConfig"]["includeServerSideToolInvocations"],
true
);
assert_eq!(gemini["toolConfig"]["functionCallingConfig"]["mode"], "ANY");
}
}
@@ -107,7 +107,8 @@ impl StreamingStandardFormatMatrix {
let client_api_format = client_api_format_for_context(report_context);
self.provider = ProviderStreamParser::for_api_format(provider_api_format.as_str());
self.client = ClientStreamEmitter::for_api_format(client_api_format.as_str());
self.client =
ClientStreamEmitter::for_api_format(client_api_format.as_str(), report_context);
}
fn emit_frames(
@@ -375,6 +376,13 @@ impl StreamingStandardTerminalObserver {
finish_reason,
usage,
} => {
if let Some(parser_error) = finish_reason
.as_deref()
.filter(|reason| !canonical_stream_finish_reason_is_supported(reason))
.map(|reason| format!("unsupported provider stream finish reason: {reason}"))
{
summary.parser_error.get_or_insert(parser_error);
}
summary.finish_reason = finish_reason;
summary.standardized_usage = usage.map(standardized_usage_from_canonical);
summary.observed_finish = true;
@@ -530,11 +538,13 @@ fn standardized_usage_from_canonical(usage: CanonicalUsage) -> StandardizedUsage
}
impl ClientStreamEmitter {
fn for_api_format(client_api_format: &str) -> Option<Self> {
fn for_api_format(client_api_format: &str, report_context: &Value) -> Option<Self> {
Some(match FormatId::parse(client_api_format)? {
FormatId::OpenAiChat => Self::OpenAIChat(OpenAIChatClientEmitter::default()),
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => {
Self::OpenAIResponses(Box::default())
Self::OpenAIResponses(Box::new(OpenAIResponsesClientEmitter::with_report_context(
report_context,
)))
}
FormatId::ClaudeMessages => Self::Claude(ClaudeClientEmitter::default()),
FormatId::GeminiGenerateContent => Self::Gemini(GeminiClientEmitter::default()),
@@ -752,7 +762,9 @@ fn parse_gemini_error(payload: &Value) -> Option<(String, Option<String>, LocalC
#[cfg(test)]
mod tests {
use super::{StreamingStandardFormatMatrix, StreamingStandardTerminalObserver};
use crate::formats::{context::FormatContext, registry::convert_request};
use crate::formats::{
context::FormatContext, openai::namespace::NamespaceToolAliases, registry::convert_request,
};
use serde_json::{json, Value};
fn report_context(provider_api_format: &str, client_api_format: &str) -> Value {
@@ -780,6 +792,48 @@ mod tests {
format!("event: {event}\n").into_bytes()
}
#[test]
fn terminal_observer_marks_malformed_gemini_function_call_as_failure() {
let context = report_context("gemini:generate_content", "openai:responses");
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(
&context,
data_line(json!({
"response": {
"responseId": "resp_malformed_tool_call",
"modelVersion": "gemini-3.7-flash-tiered",
"candidates": [{
"index": 0,
"content": {
"role": "model",
"parts": [{"thoughtSignature": "signature", "text": ""}]
},
"finishReason": "MALFORMED_FUNCTION_CALL",
"finishMessage": "Malformed function call: Function call is empty - no input to parse."
}]
},
"responseId": "resp_malformed_tool_call"
})),
)
.expect("Gemini terminal frame should parse");
let summary = observer
.finish(&context)
.expect("terminal observation should finish")
.expect("Gemini terminal frame should produce a summary");
assert!(summary.observed_finish);
assert_eq!(
summary.finish_reason.as_deref(),
Some("MALFORMED_FUNCTION_CALL")
);
assert_eq!(
summary.parser_error.as_deref(),
Some("unsupported provider stream finish reason: MALFORMED_FUNCTION_CALL")
);
}
#[test]
fn event_only_stream_types_convert_across_standard_formats() {
let responses_payload = json!({
@@ -957,6 +1011,151 @@ mod tests {
);
}
#[test]
fn streamed_chat_namespace_tool_call_restores_responses_identity() {
let report_context = json!({
"provider_api_format": "openai:chat",
"client_api_format": "openai:responses",
"mapped_model": "qwen",
"needs_conversion": true,
"original_request_body": {
"model": "qwen",
"input": [{"role": "user", "content": "write the report"}],
"tools": [
{
"type": "function",
"name": "vulnerability_report",
"parameters": {"type": "object", "properties": {}}
},
{
"type": "namespace",
"name": "mcp__vulnerability_report",
"description": "reporting tools",
"tools": [{
"type": "function",
"name": "vulnerability_report",
"description": "write the confirmed report",
"parameters": {
"type": "object",
"properties": {"report_path": {"type": "string"}},
"required": ["report_path"]
},
"strict": true
}]
}
]
}
});
let aliases = NamespaceToolAliases::from_report_context(&report_context);
let chat_name = aliases
.chat_name("mcp__vulnerability_report", "vulnerability_report")
.expect("namespace child should have a Chat alias")
.to_string();
assert_ne!(chat_name, "vulnerability_report");
let mut matrix = StreamingStandardFormatMatrix::default();
let mut output = Vec::new();
output.extend(
matrix
.transform_line(
&report_context,
data_line(json!({
"id": "chatcmpl_namespace_stream_1",
"model": "qwen",
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": 0,
"id": "call_namespace_stream_1",
"type": "function",
"function": {
"name": chat_name,
"arguments": "{\"report_path\":"
}
}]
},
"finish_reason": Value::Null
}]
})),
)
.expect("tool start should convert"),
);
output.extend(
matrix
.transform_line(
&report_context,
data_line(json!({
"id": "chatcmpl_namespace_stream_1",
"model": "qwen",
"choices": [{
"index": 0,
"delta": {
"tool_calls": [{
"index": 0,
"function": {"arguments": "\"reports/sql-001.md\"}"}
}]
},
"finish_reason": Value::Null
}]
})),
)
.expect("tool arguments should convert"),
);
output.extend(
matrix
.transform_line(
&report_context,
data_line(json!({
"id": "chatcmpl_namespace_stream_1",
"model": "qwen",
"choices": [{
"index": 0,
"delta": {},
"finish_reason": "tool_calls"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 4,
"total_tokens": 14
}
})),
)
.expect("tool finish should convert"),
);
let events = json_data_events(&output);
let added = events
.iter()
.find(|event| event["type"] == "response.output_item.added")
.expect("function-call item should start");
assert_eq!(added["item"]["name"], "vulnerability_report");
assert_eq!(added["item"]["namespace"], "mcp__vulnerability_report");
let done = events
.iter()
.find(|event| event["type"] == "response.output_item.done")
.expect("function-call item should complete");
assert_eq!(done["item"]["name"], "vulnerability_report");
assert_eq!(done["item"]["namespace"], "mcp__vulnerability_report");
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.expect("response should complete");
let function_call = completed["response"]["output"]
.as_array()
.expect("response output")
.iter()
.find(|item| item["type"] == "function_call")
.expect("completed function call");
assert_eq!(function_call["name"], "vulnerability_report");
assert_eq!(function_call["namespace"], "mcp__vulnerability_report");
let persisted = matrix
.take_response_history_record()
.expect("completed stream should expose response history");
assert!(persisted.payload.contains("mcp__vulnerability_report"));
}
#[test]
fn transforms_provider_errors_to_openai_chat_error_bodies() {
let cases = [
@@ -1168,6 +1367,17 @@ mod tests {
.expect("keepalive should be ignored");
assert!(keepalive.is_empty());
let ping = matrix
.transform_line(
&report_context,
data_line(json!({
"type": "ping",
"cost": "0",
})),
)
.expect("provider ping should be ignored");
assert!(ping.is_empty());
for line in [
data_line(json!({
"type": "response.output_text.delta",
@@ -3290,6 +3290,7 @@ struct GeminiSyncToolState {
call_id: String,
name: String,
arguments: String,
thought_signature: String,
part_index: Option<usize>,
}
@@ -3617,6 +3618,13 @@ fn try_aggregate_gemini_stream_sync_response(
parts[part_index] = sync_gemini_function_call_part(state);
}
}
CanonicalStreamEvent::ToolCallSignature { index, signature } => {
let state = tool_states.entry(index).or_default();
state.thought_signature = signature;
if let Some(part_index) = state.part_index {
parts[part_index] = sync_gemini_function_call_part(state);
}
}
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let state = tool_states.entry(index).or_default();
state.arguments.push_str(&arguments);
@@ -3811,7 +3819,7 @@ fn is_mergeable_gemini_text_part(part: &Map<String, Value>, thought: bool) -> bo
}
fn sync_gemini_function_call_part(state: &GeminiSyncToolState) -> Value {
json!({
let mut part = json!({
"functionCall": {
"id": if state.call_id.trim().is_empty() {
"call_auto_0".to_string()
@@ -3825,7 +3833,11 @@ fn sync_gemini_function_call_part(state: &GeminiSyncToolState) -> Value {
},
"args": sync_gemini_function_args_value(&state.arguments),
}
})
});
if !state.thought_signature.is_empty() {
part["thoughtSignature"] = Value::String(state.thought_signature.clone());
}
part
}
fn sync_gemini_function_response_part(
@@ -118,12 +118,14 @@ pub fn maybe_bridge_standard_sync_json_to_stream(
openai_responses_terminal_event_type(&openai_responses_response)
.unwrap_or("response.completed"),
provider_actual_service_tier.as_deref(),
&bridge_context,
)?
} else {
emit_client_stream_from_canonical_frames(
canonical_frames,
client_api_format.as_str(),
provider_actual_service_tier.as_deref(),
&bridge_context,
)?
};
@@ -167,6 +169,7 @@ fn bridge_openai_responses_same_family_sync_json_to_stream(
response,
terminal_event_type,
provider_actual_service_tier_from_sync_response(response, provider_api_format).as_deref(),
report_context,
)?;
Ok(Some(SyncToStreamBridgeOutcome {
@@ -1046,6 +1049,7 @@ fn emit_client_stream_from_canonical_frames(
canonical_frames: Vec<CanonicalStreamFrame>,
client_api_format: &str,
provider_actual_service_tier: Option<&str>,
report_context: &Value,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
match client_api_format {
"openai:chat" => {
@@ -1054,7 +1058,7 @@ fn emit_client_stream_from_canonical_frames(
emit_with_openai_chat_emitter(&mut emitter, canonical_frames)
}
"openai:responses" | "openai:responses:compact" => {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut emitter = OpenAIResponsesClientEmitter::with_report_context(report_context);
emitter.set_actual_service_tier(provider_actual_service_tier);
emit_with_openai_responses_emitter(&mut emitter, canonical_frames)
}
@@ -1115,8 +1119,9 @@ fn emit_openai_responses_stream_with_authoritative_terminal(
authoritative_response: &Value,
terminal_event_type: &'static str,
provider_actual_service_tier: Option<&str>,
report_context: &Value,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut emitter = OpenAIResponsesClientEmitter::with_report_context(report_context);
emitter.set_actual_service_tier(provider_actual_service_tier);
let mut output = Vec::new();
for frame in canonical_frames {
@@ -1316,6 +1321,7 @@ mod tests {
use serde_json::{json, Value};
use super::{maybe_bridge_standard_sync_json_to_stream, standardized_usage_from_openai_usage};
use crate::formats::openai::namespace::NamespaceToolAliases;
fn utf8(bytes: Vec<u8>) -> String {
String::from_utf8(bytes).expect("utf8 should decode")
@@ -1380,6 +1386,88 @@ mod tests {
assert!(history_record.payload.contains("resp_sync_history_1"));
}
#[test]
fn chat_sync_bridge_restores_namespaced_responses_tool_identity() {
let report_context = json!({
"provider_api_format": "openai:chat",
"client_api_format": "openai:responses",
"needs_conversion": true,
"original_request_body": {
"model": "qwen",
"input": "write the report",
"tools": [
{
"type": "function",
"name": "vulnerability_report",
"parameters": {"type": "object"}
},
{
"type": "namespace",
"name": "mcp__vulnerability_report",
"description": "Reporting tools",
"tools": [{
"type": "function",
"name": "vulnerability_report",
"parameters": {
"type": "object",
"properties": {"report_path": {"type": "string"}}
}
}]
}
]
}
});
let aliases = NamespaceToolAliases::from_report_context(&report_context);
let chat_name = aliases
.chat_name("mcp__vulnerability_report", "vulnerability_report")
.expect("namespace alias");
let outcome = maybe_bridge_standard_sync_json_to_stream(
&json!({
"id": "chatcmpl_namespace_sync_1",
"object": "chat.completion",
"model": "qwen",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_namespace_sync_1",
"type": "function",
"function": {
"name": chat_name,
"arguments": "{\"report_path\":\"reports/finding.md\"}"
}
}]
},
"finish_reason": "tool_calls"
}]
}),
"openai:chat",
"openai:responses",
Some(&report_context),
)
.expect("bridge should succeed")
.expect("bridge should produce SSE");
let body = utf8(outcome.sse_body);
let events = json_sse_events(&body);
let done = events
.iter()
.find(|event| event["type"] == "response.output_item.done")
.expect("function call should complete");
assert_eq!(done["item"]["name"], "vulnerability_report");
assert_eq!(done["item"]["namespace"], "mcp__vulnerability_report");
let completed = events
.iter()
.find(|event| event["type"] == "response.completed")
.expect("response should complete");
assert_eq!(
completed["response"]["output"][0]["namespace"],
"mcp__vulnerability_report"
);
}
#[test]
fn openai_sync_usage_derives_missing_input_tokens_from_total() {
let usage = standardized_usage_from_openai_usage(&json!({
+1
View File
@@ -12,6 +12,7 @@ pub use formats::context::{
ConversionFieldRecord, ConversionFieldStatus, ConversionReport, Converted, FormatContext,
FormatError,
};
pub use formats::gemini::generate_content::request::ensure_server_side_tool_invocations_for_mixed_tools;
pub use formats::id::{
api_format_alias_matches, api_format_defaults_to_client_error_failover,
api_format_defaults_to_non_stream, api_format_permission_covers,
@@ -1,8 +1,11 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet, VecDeque};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use crate::formats::openai::responses::{
decode_gemini_tool_signature_carrier, GeminiToolSignatureCarrierDirection,
};
use crate::formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort;
use crate::formats::openai::responses::openai_responses_message_item_id;
use crate::formats::shared::model_directives::ReasoningEffort;
@@ -1119,6 +1122,25 @@ pub(crate) fn gemini_contents_to_canonical_messages(
};
let contents = contents.as_array()?;
let mut messages = Vec::new();
let mut reserved_tool_call_ids = contents
.iter()
.filter_map(Value::as_object)
.filter_map(|content| content.get("parts"))
.filter_map(Value::as_array)
.flatten()
.filter_map(Value::as_object)
.filter_map(|part| {
part.get("functionCall")
.or_else(|| part.get("function_call"))
.or_else(|| part.get("functionResponse"))
.or_else(|| part.get("function_response"))
.and_then(Value::as_object)
.and_then(gemini_explicit_function_id)
.map(ToOwned::to_owned)
})
.collect::<BTreeSet<_>>();
let mut pending_tool_calls = VecDeque::<(String, String)>::new();
let mut next_generated_tool_call_index = 0usize;
for content in contents {
let content_object = content.as_object()?;
let role = match content_object
@@ -1137,7 +1159,77 @@ pub(crate) fn gemini_contents_to_canonical_messages(
let parts = content_object.get("parts").and_then(Value::as_array)?;
let mut blocks = Vec::new();
for (index, part) in parts.iter().enumerate() {
blocks.push(gemini_part_to_canonical_block(part, index)?);
let mut block = gemini_part_to_canonical_block(part, index)?;
match &mut block {
CanonicalContentBlock::ToolUse { id, name, .. } => {
let has_explicit_id = part
.as_object()
.and_then(|part| {
part.get("functionCall")
.or_else(|| part.get("function_call"))
})
.and_then(Value::as_object)
.and_then(gemini_explicit_function_id)
.is_some();
if !has_explicit_id {
loop {
let generated = format!("call_auto_{next_generated_tool_call_index}");
next_generated_tool_call_index += 1;
if reserved_tool_call_ids.insert(generated.clone()) {
*id = generated;
break;
}
}
}
pending_tool_calls.push_back((id.clone(), name.clone()));
}
CanonicalContentBlock::ToolResult {
tool_use_id, name, ..
} => {
let explicit_response_id = part
.as_object()
.and_then(|part| {
part.get("functionResponse")
.or_else(|| part.get("function_response"))
})
.and_then(Value::as_object)
.and_then(gemini_explicit_function_id);
let matched_position = explicit_response_id
.and_then(|response_id| {
pending_tool_calls
.iter()
.position(|(call_id, _)| call_id == response_id)
})
.or_else(|| {
if explicit_response_id.is_none() {
name.as_deref().and_then(|response_name| {
pending_tool_calls
.iter()
.position(|(_, call_name)| call_name == response_name)
})
} else {
None
}
});
let matched_call = matched_position
.and_then(|position| pending_tool_calls.remove(position))
.or_else(|| {
if explicit_response_id.is_none() && name.is_none() {
pending_tool_calls.pop_front()
} else {
None
}
});
if let Some((call_id, call_name)) = matched_call {
*tool_use_id = call_id;
if name.is_none() {
*name = Some(call_name);
}
}
}
_ => {}
}
blocks.push(block);
}
if blocks.is_empty() {
continue;
@@ -1206,11 +1298,7 @@ pub(crate) fn gemini_part_to_canonical_block(
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
let id = function_call
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
let id = gemini_explicit_function_id(function_call)
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("call_auto_{index}"));
return Some(CanonicalContentBlock::ToolUse {
@@ -1234,11 +1322,7 @@ pub(crate) fn gemini_part_to_canonical_block(
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let tool_use_id = function_response
.get("id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
let tool_use_id = gemini_explicit_function_id(function_response)
.map(ToOwned::to_owned)
.or_else(|| name.clone())
.unwrap_or_else(|| format!("toolu_response_{index}"));
@@ -1268,6 +1352,16 @@ pub(crate) fn gemini_part_to_canonical_block(
})
}
fn gemini_explicit_function_id(function: &Map<String, Value>) -> Option<&str> {
["id", "call_id", "callId"].iter().find_map(|field| {
function
.get(*field)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
})
}
pub(crate) fn gemini_inline_data_to_canonical_block(
inline_data: &Map<String, Value>,
part_object: &Map<String, Value>,
@@ -1818,13 +1912,7 @@ pub(crate) fn openai_message_content_blocks(
.unwrap_or_default()
.to_string(),
name: None,
output: match message.get("content") {
Some(Value::String(raw)) => serde_json::from_str::<Value>(raw)
.ok()
.or_else(|| Some(Value::String(raw.clone()))),
Some(value) => Some(value.clone()),
None => None,
},
output: message.get("content").cloned(),
content_text: Some(if text.is_empty() {
message
.get("content")
@@ -1982,7 +2070,31 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
.to_ascii_lowercase();
match item_type.as_str() {
"reasoning" => {
pending_reasoning = openai_responses_reasoning_block_from_item(item_object);
let reasoning = openai_responses_reasoning_block_from_item(item_object);
let previous_signature = reasoning.as_ref().and_then(|block| match block {
CanonicalContentBlock::Thinking {
text,
encrypted_content: Some(carrier),
..
} if text.trim().is_empty() => decode_gemini_tool_signature_carrier(
carrier,
)
.and_then(|(signature, direction)| {
(direction == GeminiToolSignatureCarrierDirection::Previous)
.then_some(signature)
}),
_ => None,
});
if let Some(signature) = previous_signature {
if attach_gemini_signature_to_previous_tool_use(
&mut messages,
signature,
) {
pending_reasoning = None;
continue;
}
}
pending_reasoning = reasoning;
}
"message" => {
let role = openai_role_to_canonical(
@@ -2115,7 +2227,7 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
generated
});
let raw_output = item_object.get("output");
let output = Some(parse_jsonish_value(raw_output));
let output = Some(raw_output.cloned().unwrap_or_else(|| json!({})));
let mut extensions = openai_responses_extensions(
item_object,
&[
@@ -2193,10 +2305,28 @@ fn openai_responses_opaque_input_item_message(item: &Value, raw_type: String) ->
fn append_openai_responses_tool_use(
messages: &mut Vec<CanonicalMessage>,
tool_use: CanonicalContentBlock,
mut tool_use: CanonicalContentBlock,
pending_reasoning: &mut Option<CanonicalContentBlock>,
) {
let reasoning = pending_reasoning.take();
let mut reasoning = pending_reasoning.take();
if let Some(CanonicalContentBlock::Thinking {
text,
encrypted_content: Some(carrier),
..
}) = reasoning.as_ref()
{
if text.trim().is_empty() {
if let Some((signature, GeminiToolSignatureCarrierDirection::Next)) =
decode_gemini_tool_signature_carrier(carrier)
{
if let CanonicalContentBlock::ToolUse { extensions, .. } = &mut tool_use {
canonical_extension_object_mut(extensions, "gemini")
.insert("thoughtSignature".to_string(), Value::String(signature));
reasoning = None;
}
}
}
}
if let Some(last_message) = messages.last_mut() {
if last_message.role == CanonicalRole::Assistant
&& (!is_openai_responses_input_message(&last_message.extensions)
@@ -2222,6 +2352,24 @@ fn append_openai_responses_tool_use(
});
}
fn attach_gemini_signature_to_previous_tool_use(
messages: &mut [CanonicalMessage],
signature: String,
) -> bool {
let Some(message) = messages.last_mut() else {
return false;
};
if message.role != CanonicalRole::Assistant {
return false;
}
let Some(CanonicalContentBlock::ToolUse { extensions, .. }) = message.content.last_mut() else {
return false;
};
canonical_extension_object_mut(extensions, "gemini")
.insert("thoughtSignature".to_string(), Value::String(signature));
true
}
fn canonical_assistant_message_has_visible_content(message: &CanonicalMessage) -> bool {
message.content.iter().any(|block| match block {
CanonicalContentBlock::Text { text, .. } | CanonicalContentBlock::Thinking { text, .. } => {
@@ -2551,7 +2699,7 @@ pub(crate) fn openai_responses_output_to_canonical(
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("call_auto_{index}"));
let raw_output = item_object.get("output");
let output = Some(parse_jsonish_value(raw_output));
let output = Some(raw_output.cloned().unwrap_or_else(|| json!({})));
let mut extensions = openai_responses_extensions(
item_object,
&[
@@ -3505,6 +3653,12 @@ pub(crate) fn is_claude_tool_result(extensions: &BTreeMap<String, Value>) -> boo
== Some(CLAUDE_TOOL_RESULT_SOURCE_MARKER)
}
pub(crate) fn is_cross_format_tool_result(extensions: &BTreeMap<String, Value>) -> bool {
is_claude_tool_result(extensions)
|| is_openai_chat_tool_result(extensions)
|| is_openai_responses_tool_result(extensions)
}
fn is_openai_responses_tool_result(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
@@ -8455,6 +8609,139 @@ mod tests {
assert_eq!(canonical.usage.as_ref().unwrap().cache_write_tokens, 5);
}
#[test]
fn gemini_request_pairs_parallel_idless_function_responses_by_order() {
let contents = json!([
{
"role": "model",
"parts": [
{"functionCall": {"name": "lookup", "args": {"q": "first"}}},
{"functionCall": {"name": "lookup", "args": {"q": "second"}}}
]
},
{
"role": "user",
"parts": [
{"functionResponse": {"name": "lookup", "response": {"result": "one"}}},
{"functionResponse": {"name": "lookup", "response": {"result": "two"}}}
]
}
]);
let messages = super::gemini_contents_to_canonical_messages(Some(&contents))
.expect("Gemini contents should parse");
let call_ids = messages[0]
.content
.iter()
.map(|block| match block {
CanonicalContentBlock::ToolUse { id, .. } => id.as_str(),
_ => panic!("expected tool use"),
})
.collect::<Vec<_>>();
let result_ids = messages[1]
.content
.iter()
.map(|block| match block {
CanonicalContentBlock::ToolResult { tool_use_id, .. } => tool_use_id.as_str(),
_ => panic!("expected tool result"),
})
.collect::<Vec<_>>();
assert_ne!(call_ids[0], call_ids[1]);
assert_eq!(result_ids, call_ids);
}
#[test]
fn gemini_request_pairs_idless_function_responses_by_name() {
let contents = json!([{
"role": "model",
"parts": [
{"functionCall": {"name": "first", "args": {}}},
{"functionCall": {"name": "second", "args": {}}}
]
}, {
"role": "user",
"parts": [
{"functionResponse": {"name": "second", "response": {"result": 2}}},
{"functionResponse": {"name": "first", "response": {"result": 1}}}
]
}]);
let messages = super::gemini_contents_to_canonical_messages(Some(&contents))
.expect("Gemini contents should parse");
let call_ids = messages[0]
.content
.iter()
.map(|block| match block {
CanonicalContentBlock::ToolUse { id, .. } => id.as_str(),
_ => panic!("expected tool use"),
})
.collect::<Vec<_>>();
let result_ids = messages[1]
.content
.iter()
.map(|block| match block {
CanonicalContentBlock::ToolResult { tool_use_id, .. } => tool_use_id.as_str(),
_ => panic!("expected tool result"),
})
.collect::<Vec<_>>();
assert_eq!(result_ids, vec![call_ids[1], call_ids[0]]);
}
#[test]
fn gemini_request_generated_function_call_ids_avoid_explicit_ids() {
let contents = json!([{
"role": "model",
"parts": [
{"functionCall": {"name": "first", "args": {}}},
{"functionCall": {"id": "call_auto_0", "name": "second", "args": {}}}
]
}]);
let messages = super::gemini_contents_to_canonical_messages(Some(&contents))
.expect("Gemini contents should parse");
let call_ids = messages[0]
.content
.iter()
.map(|block| match block {
CanonicalContentBlock::ToolUse { id, .. } => id.as_str(),
_ => panic!("expected tool use"),
})
.collect::<Vec<_>>();
assert_eq!(call_ids, vec!["call_auto_1", "call_auto_0"]);
}
#[test]
fn gemini_request_generated_function_call_ids_avoid_explicit_response_ids() {
let contents = json!([{
"role": "model",
"parts": [{"functionCall": {"name": "generated", "args": {}}}]
}, {
"role": "user",
"parts": [{
"functionResponse": {
"id": "call_auto_0",
"name": "external",
"response": {"result": "done"}
}
}]
}]);
let messages = super::gemini_contents_to_canonical_messages(Some(&contents))
.expect("Gemini contents should parse");
let CanonicalContentBlock::ToolUse { id: call_id, .. } = &messages[0].content[0] else {
panic!("expected tool use");
};
let CanonicalContentBlock::ToolResult { tool_use_id, .. } = &messages[1].content[0] else {
panic!("expected tool result");
};
assert_eq!(call_id, "call_auto_1");
assert_eq!(tool_use_id, "call_auto_0");
}
#[test]
fn gemini_request_adapter_preserves_thinking_tools_media_and_extensions() {
let request = json!({
@@ -60,6 +60,10 @@ pub enum CanonicalStreamEvent {
call_id: String,
name: String,
},
ToolCallSignature {
index: usize,
signature: String,
},
ToolCallArgumentsDelta {
index: usize,
arguments: String,