feat: audit strict format conversion contracts

This commit is contained in:
elky
2026-06-03 20:27:15 +08:00
parent 5ac153a0bb
commit 7507af5829
28 changed files with 12206 additions and 203 deletions
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use crate::formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort;
use crate::formats::shared::model_directives::ReasoningEffort;
use crate::formats::shared::response::remove_empty_pages_from_tool_input_value;
pub use crate::protocol::stream::{CanonicalStreamEvent, CanonicalStreamFrame};
@@ -186,6 +187,8 @@ pub struct CanonicalToolDefinition {
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parameters: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub extensions: BTreeMap<String, Value>,
}
@@ -3209,10 +3212,18 @@ pub(crate) fn claude_tools_to_canonical(
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: tool_object.get("input_schema").cloned(),
extensions: claude_extensions(
tool_object,
&["type", "name", "description", "input_schema"],
),
strict: None,
extensions: {
let mut extensions = claude_extensions(
tool_object,
&["type", "name", "description", "input_schema"],
);
if let Some(input_schema) = tool_object.get("input_schema").cloned() {
canonical_extension_object_mut(&mut extensions, "claude")
.insert("raw_input_schema".to_string(), input_schema);
}
extensions
},
});
}
Some((canonical, builtin_tools, web_search_options))
@@ -3361,14 +3372,7 @@ pub(crate) fn claude_thinking_to_canonical(
}
pub(crate) fn claude_output_effort_to_openai_reasoning_effort(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
"xhigh" => Some("xhigh"),
"max" => Some("max"),
_ => None,
}
ReasoningEffort::parse(value).map(ReasoningEffort::as_openai_chat_value)
}
pub(crate) fn canonical_openai_reasoning_effort(
@@ -3416,13 +3420,20 @@ pub(crate) fn gemini_thinking_to_canonical(
.get("thinkingBudget")
.or_else(|| thinking_config.get("thinking_budget"))
.and_then(Value::as_u64);
let thinking_level = thinking_config
.get("thinkingLevel")
.or_else(|| thinking_config.get("thinking_level"))
.and_then(Value::as_str)
.and_then(ReasoningEffort::parse)
.map(ReasoningEffort::as_openai_chat_value);
let mut extensions = BTreeMap::new();
extensions.insert(
"gemini".to_string(),
json!({ "thinking_config": Value::Object(thinking_config.clone()) }),
);
if let Some(reasoning_effort) =
budget_tokens.map(map_thinking_budget_to_openai_reasoning_effort)
if let Some(reasoning_effort) = budget_tokens
.map(map_thinking_budget_to_openai_reasoning_effort)
.or(thinking_level)
{
extensions.insert(
"openai".to_string(),
@@ -3625,10 +3636,18 @@ pub(crate) fn gemini_tools_to_canonical(value: Option<&Value>) -> Option<GeminiC
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: declaration_object.get("parameters").cloned(),
extensions: gemini_extensions(
declaration_object,
&["name", "description", "parameters"],
),
strict: None,
extensions: {
let mut extensions = gemini_extensions(
declaration_object,
&["name", "description", "parameters"],
);
if let Some(parameters) = declaration_object.get("parameters").cloned() {
canonical_extension_object_mut(&mut extensions, "gemini")
.insert("raw_parameters".to_string(), parameters);
}
extensions
},
});
}
}
@@ -3761,6 +3780,7 @@ pub(crate) fn openai_tools_to_canonical(
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: function.get("parameters").cloned(),
strict: function.get("strict").and_then(Value::as_bool),
extensions: openai_extensions(tool_object, &["type", "function"]),
})
})
@@ -3792,8 +3812,10 @@ pub(crate) fn openai_responses_tools_to_canonical(
.filter(|value| !value.is_empty())?;
let mut extensions =
openai_responses_extensions(tool_object, &["type", "function"]);
let function_extensions =
openai_responses_extensions(function, &["name", "description", "parameters"]);
let function_extensions = openai_responses_extensions(
function,
&["name", "description", "parameters", "strict"],
);
merge_tool_extensions(&mut extensions, function_extensions);
canonical.push(CanonicalToolDefinition {
name: name.to_string(),
@@ -3802,6 +3824,7 @@ pub(crate) fn openai_responses_tools_to_canonical(
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: function.get("parameters").cloned(),
strict: function.get("strict").and_then(Value::as_bool),
extensions,
});
continue;
@@ -3818,9 +3841,10 @@ pub(crate) fn openai_responses_tools_to_canonical(
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: tool_object.get("parameters").cloned(),
strict: tool_object.get("strict").and_then(Value::as_bool),
extensions: openai_responses_extensions(
tool_object,
&["type", "name", "description", "parameters"],
&["type", "name", "description", "parameters", "strict"],
),
});
} else if tool_type == "custom" {
@@ -3853,6 +3877,7 @@ pub(crate) fn openai_responses_tools_to_canonical(
name: name.to_string(),
description,
parameters,
strict: tool_object.get("strict").and_then(Value::as_bool),
extensions: BTreeMap::from([(
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
tool.clone(),
@@ -3863,6 +3888,7 @@ pub(crate) fn openai_responses_tools_to_canonical(
name: tool_type,
description: None,
parameters: None,
strict: None,
extensions: BTreeMap::from([(
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
tool.clone(),
@@ -3898,6 +3924,9 @@ pub(crate) fn canonical_tool_to_openai(tool: &CanonicalToolDefinition) -> Value
if let Some(parameters) = &tool.parameters {
function.insert("parameters".to_string(), parameters.clone());
}
if let Some(strict) = tool.strict {
function.insert("strict".to_string(), Value::Bool(strict));
}
json!({
"type": "function",
"function": Value::Object(function),
@@ -4428,9 +4457,18 @@ pub(crate) fn canonical_tools_to_claude(canonical: &CanonicalRequest) -> Vec<Val
}
out.insert(
"input_schema".to_string(),
claude_input_schema_from_tool_parameters(tool.parameters.as_ref()),
tool.extensions
.get("claude")
.and_then(Value::as_object)
.and_then(|value| value.get("raw_input_schema"))
.cloned()
.unwrap_or_else(|| {
claude_input_schema_from_tool_parameters(tool.parameters.as_ref())
}),
);
out.extend(namespace_extension_object(&tool.extensions, "claude", &out));
let mut extra = namespace_extension_object(&tool.extensions, "claude", &out);
extra.remove("raw_input_schema");
out.extend(extra);
Value::Object(out)
})
.collect::<Vec<_>>();
@@ -4912,9 +4950,15 @@ pub(crate) fn gemini_stop_reason_to_canonical(value: &str) -> Option<CanonicalSt
Some(match value.trim().to_ascii_uppercase().as_str() {
"STOP" => CanonicalStopReason::EndTurn,
"MAX_TOKENS" => CanonicalStopReason::MaxTokens,
"SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII" => {
CanonicalStopReason::ContentFiltered
}
"SAFETY"
| "RECITATION"
| "LANGUAGE"
| "BLOCKLIST"
| "PROHIBITED_CONTENT"
| "SPII"
| "IMAGE_SAFETY"
| "IMAGE_PROHIBITED_CONTENT"
| "IMAGE_RECITATION" => CanonicalStopReason::ContentFiltered,
"OTHER" => CanonicalStopReason::Unknown,
_ => CanonicalStopReason::Unknown,
})