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