Merge remote-tracking branch 'origin/pr/619'

This commit is contained in:
elky
2026-06-02 19:23:44 +08:00
5 changed files with 403 additions and 21 deletions
@@ -16,8 +16,8 @@ use crate::{
claude_generation_config, claude_messages_to_canonical, claude_parallel_tool_calls,
claude_system_to_canonical_instructions, claude_thinking_to_canonical,
claude_tool_choice_to_canonical, claude_tools_to_canonical,
compact_canonical_claude_messages, insert_f64, namespace_extension_object,
CanonicalRequest,
compact_canonical_claude_messages, insert_f64, mark_claude_messages_request_source,
namespace_extension_object, CanonicalRequest,
},
};
@@ -85,6 +85,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
"output_config",
],
);
mark_claude_messages_request_source(&mut canonical.extensions);
if !builtin_tools.is_empty() {
canonical_extension_object_mut(&mut canonical.extensions, "claude")
.insert("builtin_tools".to_string(), Value::Array(builtin_tools));
@@ -115,7 +116,7 @@ pub fn to_raw(
);
output.insert(
"max_tokens".to_string(),
Value::from(canonical.generation.max_tokens.unwrap_or(1024)),
Value::from(canonical.generation.max_tokens.unwrap_or(8192)),
);
if let Some(system) = canonical_instructions_to_claude_system(&canonical.instructions) {
output.insert("system".to_string(), system);
@@ -668,6 +668,142 @@ mod tests {
assert!(!block_content_json.contains("document body"));
}
#[test]
fn claude_request_to_responses_uses_developer_system_and_sub2api_defaults() {
let body = json!({
"model": "claude-sonnet",
"system": [{
"type": "text",
"text": "Be exact.",
"cache_control": {"type": "ephemeral"}
}],
"messages": [
{"role": "user", "content": "hello"},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "private plan", "signature": "sig_hidden"},
{"type": "text", "text": "visible answer"},
{
"type": "tool_use",
"id": "toolu_calc",
"name": "calc",
"input": {"x": 1}
}
]
}
],
"tools": [
{"name": "implicit_empty", "description": "empty"},
{"name": "object_empty", "input_schema": {"type": "object"}}
],
"thinking": {"type": "enabled", "budget_tokens": 4096},
"temperature": 0.2,
"top_p": 0.9,
"max_tokens": 10,
});
let converted = registry::convert_request(
"claude:messages",
"openai:responses",
&body,
&FormatContext::default().with_mapped_model("gpt-5.1"),
)
.expect("responses request");
assert_eq!(converted["model"], "gpt-5.1");
assert!(converted.get("temperature").is_none());
assert!(converted.get("top_p").is_none());
assert!(converted.get("instructions").is_none());
assert_eq!(converted["text"]["verbosity"], "medium");
assert_eq!(converted["reasoning"]["effort"], "medium");
assert_eq!(converted["reasoning"]["summary"], "auto");
assert_eq!(converted["max_output_tokens"], 128);
assert_eq!(converted["store"], false);
assert_eq!(converted["parallel_tool_calls"], true);
assert!(converted["include"]
.as_array()
.expect("include")
.iter()
.any(|value| value.as_str() == Some("reasoning.encrypted_content")));
let input = converted["input"].as_array().expect("responses input");
assert_eq!(input[0]["role"], "developer");
assert_eq!(input[0]["content"][0]["type"], "input_text");
assert_eq!(input[0]["content"][0]["text"], "Be exact.");
assert_eq!(
input[0]["content"][0]["cache_control"],
json!({"type": "ephemeral"})
);
let input_json = Value::Array(input.clone()).to_string();
assert!(input_json.contains("visible answer"));
assert!(!input_json.contains("private plan"));
assert!(!input_json.contains("sig_hidden"));
let tools = converted["tools"].as_array().expect("tools");
assert_eq!(tools.len(), 2);
for tool in tools {
assert_eq!(tool["parameters"]["type"], "object");
assert!(tool["parameters"]["properties"].is_object());
}
}
#[test]
fn claude_output_config_effort_controls_responses_reasoning() {
let body = json!({
"model": "claude-sonnet",
"messages": [{"role": "user", "content": "hello"}],
"thinking": {"type": "enabled", "budget_tokens": 1024},
"output_config": {"effort": "max"},
"max_tokens": 128,
});
let converted = registry::convert_request(
"claude:messages",
"openai:responses",
&body,
&FormatContext::default(),
)
.expect("responses request");
assert_eq!(converted["reasoning"]["effort"], "xhigh");
assert_eq!(converted["reasoning"]["summary"], "auto");
}
#[test]
fn responses_to_claude_defaults_max_tokens_and_omits_false_is_error() {
let body = json!({
"model": "gpt-5",
"input": [
{
"type": "function_call_output",
"call_id": "toolu_ok",
"output": "ok",
"is_error": false
},
{
"type": "function_call_output",
"call_id": "toolu_bad",
"output": "bad",
"is_error": true
}
]
});
let converted = registry::convert_request(
"openai:responses",
"claude:messages",
&body,
&FormatContext::default(),
)
.expect("claude request");
assert_eq!(converted["max_tokens"], 8192);
let messages_json = converted["messages"].to_string();
assert!(!messages_json.contains("\"is_error\":false"));
assert!(messages_json.contains("\"is_error\":true"));
}
#[test]
fn claude_request_to_responses_splits_tool_result_media_from_output() {
let body = json!({
@@ -6,9 +6,10 @@ use crate::{
formats::context::FormatContext,
formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort,
protocol::canonical::{
canonical_response_format_to_openai, canonicalize_tool_arguments, is_claude_tool_result,
media_data_or_url, namespace_extension_object, openai_content_text, openai_extensions,
openai_response_format_to_canonical, openai_responses_extension,
canonical_response_format_to_openai, canonicalize_tool_arguments,
is_claude_messages_request, is_claude_system_instruction, is_claude_thinking_block,
is_claude_tool_result, media_data_or_url, namespace_extension_object, openai_content_text,
openai_extensions, openai_response_format_to_canonical, openai_responses_extension,
openai_responses_generation_config, openai_responses_input_to_canonical_messages,
openai_responses_tool_choice_to_canonical, openai_responses_tools_to_canonical,
CanonicalContentBlock, CanonicalInstruction, CanonicalRequest, CanonicalRole,
@@ -137,13 +138,18 @@ pub fn to_raw(
output.insert("instructions".to_string(), instructions);
}
let mut input = canonical_messages_to_responses_input(canonical)?;
if let Some(developer_message) =
claude_system_instructions_to_responses_developer_message(canonical)
{
input.insert(0, developer_message);
}
ensure_json_object_response_input_mentions_json(canonical, instructions.as_ref(), &mut input);
output.insert("input".to_string(), Value::Array(input));
if upstream_is_stream && !compact {
output.insert("stream".to_string(), Value::Bool(true));
}
if let Some(max_tokens) = canonical.generation.max_tokens {
if let Some(max_tokens) = responses_max_output_tokens(canonical) {
output.insert("max_output_tokens".to_string(), Value::from(max_tokens));
}
insert_number(&mut output, "temperature", canonical.generation.temperature);
@@ -172,11 +178,7 @@ pub fn to_raw(
canonical_tool_choice_to_responses(tool_choice),
);
}
if let Some(reasoning) = canonical
.thinking
.as_ref()
.and_then(reasoning_config_to_responses)
{
if let Some(reasoning) = canonical_reasoning_config_to_responses(canonical) {
output.insert("reasoning".to_string(), reasoning);
}
@@ -190,6 +192,7 @@ pub fn to_raw(
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
&output,
));
apply_claude_responses_request_defaults(canonical, mapped_model, &mut output);
if compact {
output.remove("stream");
}
@@ -201,6 +204,7 @@ fn canonical_instructions_to_responses(canonical: &CanonicalRequest) -> Option<V
let text = canonical
.instructions
.iter()
.filter(|instruction| !is_claude_system_instruction(instruction))
.map(|instruction| instruction.text.as_str())
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
@@ -208,6 +212,13 @@ fn canonical_instructions_to_responses(canonical: &CanonicalRequest) -> Option<V
if !text.trim().is_empty() {
return Some(Value::String(text));
}
if canonical
.instructions
.iter()
.any(is_claude_system_instruction)
{
return None;
}
canonical
.system
.as_ref()
@@ -216,6 +227,41 @@ fn canonical_instructions_to_responses(canonical: &CanonicalRequest) -> Option<V
.map(Value::String)
}
fn claude_system_instructions_to_responses_developer_message(
canonical: &CanonicalRequest,
) -> Option<Value> {
let content = canonical
.instructions
.iter()
.filter(|instruction| is_claude_system_instruction(instruction))
.filter_map(claude_system_instruction_to_responses_part)
.collect::<Vec<_>>();
(!content.is_empty()).then(|| {
json!({
"type": "message",
"role": "developer",
"content": content,
})
})
}
fn claude_system_instruction_to_responses_part(
instruction: &CanonicalInstruction,
) -> Option<Value> {
if instruction.text.trim().is_empty() {
return None;
}
let mut part = Map::new();
part.insert("type".to_string(), Value::String("input_text".to_string()));
part.insert("text".to_string(), Value::String(instruction.text.clone()));
part.extend(namespace_extension_object(
&instruction.extensions,
"claude",
&part,
));
Some(Value::Object(part))
}
fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option<Vec<Value>> {
let mut input = Vec::new();
for message in &canonical.messages {
@@ -270,7 +316,12 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
}));
}
}
CanonicalContentBlock::Thinking { text, .. } => {
CanonicalContentBlock::Thinking {
text, extensions, ..
} => {
if is_claude_thinking_block(extensions) {
continue;
}
if role == "assistant" && !text.trim().is_empty() {
content.push(json!({
"type": "output_text",
@@ -309,6 +360,49 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
Some(input)
}
fn responses_max_output_tokens(canonical: &CanonicalRequest) -> Option<u64> {
canonical.generation.max_tokens.map(|max_tokens| {
if is_claude_messages_request(&canonical.extensions) && max_tokens < 128 {
128
} else {
max_tokens
}
})
}
fn apply_claude_responses_request_defaults(
canonical: &CanonicalRequest,
mapped_model: &str,
output: &mut Map<String, Value>,
) {
if !is_claude_messages_request(&canonical.extensions) {
return;
}
if mapped_model
.trim()
.to_ascii_lowercase()
.starts_with("gpt-5")
{
output.remove("temperature");
output.remove("top_p");
}
output
.entry("store".to_string())
.or_insert_with(|| Value::Bool(false));
output
.entry("parallel_tool_calls".to_string())
.or_insert_with(|| Value::Bool(true));
let include = output
.entry("include".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if let Some(include) = include.as_array_mut() {
let encrypted_content = Value::String("reasoning.encrypted_content".to_string());
if !include.iter().any(|value| value == &encrypted_content) {
include.push(encrypted_content);
}
}
}
fn ensure_json_object_response_input_mentions_json(
canonical: &CanonicalRequest,
instructions: Option<&Value>,
@@ -463,6 +557,42 @@ fn canonical_tools_to_responses(canonical: &CanonicalRequest) -> Vec<Value> {
tools
}
fn canonical_reasoning_config_to_responses(canonical: &CanonicalRequest) -> Option<Value> {
let is_claude_request = is_claude_messages_request(&canonical.extensions);
if !is_claude_request {
return canonical
.thinking
.as_ref()
.and_then(reasoning_config_to_responses);
}
let mut object = canonical
.thinking
.as_ref()
.and_then(|thinking| openai_responses_extension(&thinking.extensions).cloned())
.and_then(|value| match value {
Value::Object(object) => Some(object),
_ => None,
})
.unwrap_or_default();
let effort = canonical
.thinking
.as_ref()
.and_then(|thinking| thinking.extensions.get("claude"))
.and_then(|value| value.get("output_config"))
.and_then(|value| value.get("effort"))
.and_then(Value::as_str)
.map(openai_responses_reasoning_effort)
.unwrap_or("medium");
object
.entry("effort".to_string())
.or_insert_with(|| Value::String(effort.to_string()));
object
.entry("summary".to_string())
.or_insert_with(|| Value::String("auto".to_string()));
Some(Value::Object(object))
}
fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<Value> {
openai_responses_extension(&thinking.extensions)
.cloned()
@@ -525,6 +655,10 @@ fn canonical_text_config_to_responses(canonical: &CanonicalRequest) -> Option<Va
{
text.insert("verbosity".to_string(), verbosity);
}
if is_claude_messages_request(&canonical.extensions) {
text.entry("verbosity".to_string())
.or_insert_with(|| Value::String("medium".to_string()));
}
(!text.is_empty()).then_some(Value::Object(text))
}
@@ -556,9 +690,10 @@ fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
Value::String(description.clone()),
);
}
if let Some(parameters) = &tool.parameters {
out.insert("parameters".to_string(), parameters.clone());
}
out.insert(
"parameters".to_string(),
responses_tool_parameters_schema(tool.parameters.as_ref()),
);
out.extend(namespace_extension_object(
&tool.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
@@ -567,6 +702,25 @@ fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
Value::Object(out)
}
fn responses_tool_parameters_schema(parameters: Option<&Value>) -> Value {
match parameters {
Some(Value::Object(schema)) => {
let mut schema = schema.clone();
if schema
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value == "object")
&& !schema.contains_key("properties")
{
schema.insert("properties".to_string(), json!({}));
}
Value::Object(schema)
}
Some(Value::Null) | None => json!({"type": "object", "properties": {}}),
Some(value) => value.clone(),
}
}
fn canonical_tool_choice_to_responses(choice: &CanonicalToolChoice) -> Value {
match choice {
CanonicalToolChoice::Auto => Value::String("auto".to_string()),
@@ -1079,6 +1079,33 @@ mod tests {
None,
)
.expect("typed canonical claude route should build");
if matches!(
provider_api_format,
"openai:responses" | "openai:responses:compact"
) {
assert!(converted.get("instructions").is_none());
assert_eq!(converted["input"][0]["role"], "developer");
assert_eq!(converted["input"][0]["content"][0]["text"], "Be exact.");
assert_eq!(converted["max_output_tokens"], 128);
assert_eq!(converted["text"]["verbosity"], "medium");
assert_eq!(converted["reasoning"]["effort"], "medium");
assert_eq!(converted["reasoning"]["summary"], "auto");
if provider_api_format == "openai:responses" {
assert_eq!(converted["store"], false);
assert!(converted["include"]
.as_array()
.expect("include")
.iter()
.any(|value| value.as_str() == Some("reasoning.encrypted_content")));
} else {
assert!(converted.get("store").is_none());
assert!(converted.get("include").is_none());
}
let input_json = converted["input"].to_string();
assert!(!input_json.contains("<thinking>plan</thinking>"));
assert!(!input_json.contains("sig_123"));
continue;
}
let legacy =
legacy_claude_request_body(&request, provider_api_format, upstream_is_stream);
assert_eq!(
@@ -11,6 +11,9 @@ pub use crate::protocol::stream::{CanonicalStreamEvent, CanonicalStreamFrame};
pub(crate) const OPENAI_RESPONSES_EXTENSION_NAMESPACE: &str = "openai_responses";
pub(crate) const OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE: &str = "openai_cli";
const AETHER_EXTENSION_NAMESPACE: &str = "aether";
const CLAUDE_MESSAGES_REQUEST_SOURCE_MARKER: &str = "claude_messages_request";
const CLAUDE_SYSTEM_SOURCE_MARKER: &str = "claude_system";
const CLAUDE_THINKING_SOURCE_MARKER: &str = "claude_thinking";
const CLAUDE_TOOL_RESULT_SOURCE_MARKER: &str = "claude_tool_result";
const OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER: &str = "openai_chat_tool_result";
const OPENAI_RESPONSES_TOOL_RESULT_SOURCE_MARKER: &str = "openai_responses_tool_result";
@@ -1052,7 +1055,7 @@ pub(crate) fn claude_system_to_canonical_instructions(
Some(vec![CanonicalInstruction {
role: CanonicalRole::System,
text,
extensions: BTreeMap::new(),
extensions: claude_system_instruction_extensions(BTreeMap::new()),
}])
}
}
@@ -1071,7 +1074,10 @@ pub(crate) fn claude_system_to_canonical_instructions(
instructions.push(CanonicalInstruction {
role: CanonicalRole::System,
text: strip_claude_billing_header(text),
extensions: claude_extensions(block, &["type", "text"]),
extensions: claude_system_instruction_extensions(claude_extensions(
block,
&["type", "text"],
)),
});
}
}
@@ -1081,6 +1087,40 @@ pub(crate) fn claude_system_to_canonical_instructions(
}
}
fn claude_system_instruction_extensions(
mut extensions: BTreeMap<String, Value>,
) -> BTreeMap<String, Value> {
canonical_extension_object_mut(&mut extensions, AETHER_EXTENSION_NAMESPACE).insert(
"source".to_string(),
Value::String(CLAUDE_SYSTEM_SOURCE_MARKER.to_string()),
);
extensions
}
pub(crate) fn mark_claude_messages_request_source(extensions: &mut BTreeMap<String, Value>) {
canonical_extension_object_mut(extensions, AETHER_EXTENSION_NAMESPACE).insert(
"source".to_string(),
Value::String(CLAUDE_MESSAGES_REQUEST_SOURCE_MARKER.to_string()),
);
}
pub(crate) fn is_claude_messages_request(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
.and_then(|value| value.get("source"))
.and_then(Value::as_str)
== Some(CLAUDE_MESSAGES_REQUEST_SOURCE_MARKER)
}
pub(crate) fn is_claude_system_instruction(instruction: &CanonicalInstruction) -> bool {
instruction
.extensions
.get(AETHER_EXTENSION_NAMESPACE)
.and_then(|value| value.get("source"))
.and_then(Value::as_str)
== Some(CLAUDE_SYSTEM_SOURCE_MARKER)
}
pub(crate) fn claude_messages_to_canonical(
messages: Option<&Value>,
) -> Option<Vec<CanonicalMessage>> {
@@ -1174,7 +1214,10 @@ pub(crate) fn claude_block_to_canonical_block(block: &Value) -> Option<Canonical
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
encrypted_content: None,
extensions: claude_extensions(block_object, &["type", "thinking", "text", "signature"]),
extensions: claude_thinking_extensions(claude_extensions(
block_object,
&["type", "thinking", "text", "signature"],
)),
}),
"redacted_thinking" => Some(CanonicalContentBlock::Thinking {
text: String::new(),
@@ -1183,7 +1226,10 @@ pub(crate) fn claude_block_to_canonical_block(block: &Value) -> Option<Canonical
.get("data")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
extensions: claude_extensions(block_object, &["type", "data"]),
extensions: claude_thinking_extensions(claude_extensions(
block_object,
&["type", "data"],
)),
}),
"image" => claude_media_block_to_canonical(block_object, true),
"document" => claude_media_block_to_canonical(block_object, false),
@@ -2546,6 +2592,22 @@ fn canonical_tool_result_to_openai_chat(block: &CanonicalContentBlock) -> Value
Value::Object(output)
}
fn claude_thinking_extensions(mut extensions: BTreeMap<String, Value>) -> BTreeMap<String, Value> {
canonical_extension_object_mut(&mut extensions, AETHER_EXTENSION_NAMESPACE).insert(
"source".to_string(),
Value::String(CLAUDE_THINKING_SOURCE_MARKER.to_string()),
);
extensions
}
pub(crate) fn is_claude_thinking_block(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
.and_then(|value| value.get("source"))
.and_then(Value::as_str)
== Some(CLAUDE_THINKING_SOURCE_MARKER)
}
pub(crate) fn is_claude_tool_result(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
@@ -4108,7 +4170,9 @@ pub(crate) fn canonical_block_to_claude(
extensions,
),
);
out.insert("is_error".to_string(), Value::Bool(*is_error));
if *is_error {
out.insert("is_error".to_string(), Value::Bool(true));
}
out.extend(namespace_extension_object(extensions, "claude", &out));
Some(Some(Value::Object(out)))
}