mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
fix(provider): preserve Claude messages defaults in responses conversion
This commit is contained in:
@@ -16,8 +16,8 @@ use crate::{
|
|||||||
claude_generation_config, claude_messages_to_canonical, claude_parallel_tool_calls,
|
claude_generation_config, claude_messages_to_canonical, claude_parallel_tool_calls,
|
||||||
claude_system_to_canonical_instructions, claude_thinking_to_canonical,
|
claude_system_to_canonical_instructions, claude_thinking_to_canonical,
|
||||||
claude_tool_choice_to_canonical, claude_tools_to_canonical,
|
claude_tool_choice_to_canonical, claude_tools_to_canonical,
|
||||||
compact_canonical_claude_messages, insert_f64, namespace_extension_object,
|
compact_canonical_claude_messages, insert_f64, mark_claude_messages_request_source,
|
||||||
CanonicalRequest,
|
namespace_extension_object, CanonicalRequest,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,6 +85,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
|
|||||||
"output_config",
|
"output_config",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
mark_claude_messages_request_source(&mut canonical.extensions);
|
||||||
if !builtin_tools.is_empty() {
|
if !builtin_tools.is_empty() {
|
||||||
canonical_extension_object_mut(&mut canonical.extensions, "claude")
|
canonical_extension_object_mut(&mut canonical.extensions, "claude")
|
||||||
.insert("builtin_tools".to_string(), Value::Array(builtin_tools));
|
.insert("builtin_tools".to_string(), Value::Array(builtin_tools));
|
||||||
@@ -115,7 +116,7 @@ pub fn to_raw(
|
|||||||
);
|
);
|
||||||
output.insert(
|
output.insert(
|
||||||
"max_tokens".to_string(),
|
"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) {
|
if let Some(system) = canonical_instructions_to_claude_system(&canonical.instructions) {
|
||||||
output.insert("system".to_string(), system);
|
output.insert("system".to_string(), system);
|
||||||
|
|||||||
@@ -668,6 +668,142 @@ mod tests {
|
|||||||
assert!(!block_content_json.contains("document body"));
|
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]
|
#[test]
|
||||||
fn claude_request_to_responses_splits_tool_result_media_from_output() {
|
fn claude_request_to_responses_splits_tool_result_media_from_output() {
|
||||||
let body = json!({
|
let body = json!({
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ use crate::{
|
|||||||
formats::context::FormatContext,
|
formats::context::FormatContext,
|
||||||
formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort,
|
formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort,
|
||||||
protocol::canonical::{
|
protocol::canonical::{
|
||||||
canonical_response_format_to_openai, canonicalize_tool_arguments, is_claude_tool_result,
|
canonical_response_format_to_openai, canonicalize_tool_arguments,
|
||||||
media_data_or_url, namespace_extension_object, openai_content_text, openai_extensions,
|
is_claude_messages_request, is_claude_system_instruction, is_claude_thinking_block,
|
||||||
openai_response_format_to_canonical, openai_responses_extension,
|
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_generation_config, openai_responses_input_to_canonical_messages,
|
||||||
openai_responses_tool_choice_to_canonical, openai_responses_tools_to_canonical,
|
openai_responses_tool_choice_to_canonical, openai_responses_tools_to_canonical,
|
||||||
CanonicalContentBlock, CanonicalInstruction, CanonicalRequest, CanonicalRole,
|
CanonicalContentBlock, CanonicalInstruction, CanonicalRequest, CanonicalRole,
|
||||||
@@ -137,13 +138,18 @@ pub fn to_raw(
|
|||||||
output.insert("instructions".to_string(), instructions);
|
output.insert("instructions".to_string(), instructions);
|
||||||
}
|
}
|
||||||
let mut input = canonical_messages_to_responses_input(canonical)?;
|
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);
|
ensure_json_object_response_input_mentions_json(canonical, instructions.as_ref(), &mut input);
|
||||||
output.insert("input".to_string(), Value::Array(input));
|
output.insert("input".to_string(), Value::Array(input));
|
||||||
|
|
||||||
if upstream_is_stream && !compact {
|
if upstream_is_stream && !compact {
|
||||||
output.insert("stream".to_string(), Value::Bool(true));
|
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));
|
output.insert("max_output_tokens".to_string(), Value::from(max_tokens));
|
||||||
}
|
}
|
||||||
insert_number(&mut output, "temperature", canonical.generation.temperature);
|
insert_number(&mut output, "temperature", canonical.generation.temperature);
|
||||||
@@ -172,11 +178,7 @@ pub fn to_raw(
|
|||||||
canonical_tool_choice_to_responses(tool_choice),
|
canonical_tool_choice_to_responses(tool_choice),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(reasoning) = canonical
|
if let Some(reasoning) = canonical_reasoning_config_to_responses(canonical) {
|
||||||
.thinking
|
|
||||||
.as_ref()
|
|
||||||
.and_then(reasoning_config_to_responses)
|
|
||||||
{
|
|
||||||
output.insert("reasoning".to_string(), reasoning);
|
output.insert("reasoning".to_string(), reasoning);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +192,7 @@ pub fn to_raw(
|
|||||||
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||||
&output,
|
&output,
|
||||||
));
|
));
|
||||||
|
apply_claude_responses_request_defaults(canonical, mapped_model, &mut output);
|
||||||
if compact {
|
if compact {
|
||||||
output.remove("stream");
|
output.remove("stream");
|
||||||
}
|
}
|
||||||
@@ -201,6 +204,7 @@ fn canonical_instructions_to_responses(canonical: &CanonicalRequest) -> Option<V
|
|||||||
let text = canonical
|
let text = canonical
|
||||||
.instructions
|
.instructions
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|instruction| !is_claude_system_instruction(instruction))
|
||||||
.map(|instruction| instruction.text.as_str())
|
.map(|instruction| instruction.text.as_str())
|
||||||
.filter(|text| !text.trim().is_empty())
|
.filter(|text| !text.trim().is_empty())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
@@ -208,6 +212,13 @@ fn canonical_instructions_to_responses(canonical: &CanonicalRequest) -> Option<V
|
|||||||
if !text.trim().is_empty() {
|
if !text.trim().is_empty() {
|
||||||
return Some(Value::String(text));
|
return Some(Value::String(text));
|
||||||
}
|
}
|
||||||
|
if canonical
|
||||||
|
.instructions
|
||||||
|
.iter()
|
||||||
|
.any(is_claude_system_instruction)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
canonical
|
canonical
|
||||||
.system
|
.system
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -216,6 +227,41 @@ fn canonical_instructions_to_responses(canonical: &CanonicalRequest) -> Option<V
|
|||||||
.map(Value::String)
|
.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>> {
|
fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option<Vec<Value>> {
|
||||||
let mut input = Vec::new();
|
let mut input = Vec::new();
|
||||||
for message in &canonical.messages {
|
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() {
|
if role == "assistant" && !text.trim().is_empty() {
|
||||||
content.push(json!({
|
content.push(json!({
|
||||||
"type": "output_text",
|
"type": "output_text",
|
||||||
@@ -309,6 +360,49 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
|||||||
Some(input)
|
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(
|
fn ensure_json_object_response_input_mentions_json(
|
||||||
canonical: &CanonicalRequest,
|
canonical: &CanonicalRequest,
|
||||||
instructions: Option<&Value>,
|
instructions: Option<&Value>,
|
||||||
@@ -463,6 +557,42 @@ fn canonical_tools_to_responses(canonical: &CanonicalRequest) -> Vec<Value> {
|
|||||||
tools
|
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> {
|
fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<Value> {
|
||||||
openai_responses_extension(&thinking.extensions)
|
openai_responses_extension(&thinking.extensions)
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -525,6 +655,10 @@ fn canonical_text_config_to_responses(canonical: &CanonicalRequest) -> Option<Va
|
|||||||
{
|
{
|
||||||
text.insert("verbosity".to_string(), verbosity);
|
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))
|
(!text.is_empty()).then_some(Value::Object(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,9 +690,10 @@ fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
|
|||||||
Value::String(description.clone()),
|
Value::String(description.clone()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(parameters) = &tool.parameters {
|
out.insert(
|
||||||
out.insert("parameters".to_string(), parameters.clone());
|
"parameters".to_string(),
|
||||||
}
|
responses_tool_parameters_schema(tool.parameters.as_ref()),
|
||||||
|
);
|
||||||
out.extend(namespace_extension_object(
|
out.extend(namespace_extension_object(
|
||||||
&tool.extensions,
|
&tool.extensions,
|
||||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
|
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
|
||||||
@@ -567,6 +702,25 @@ fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
|
|||||||
Value::Object(out)
|
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 {
|
fn canonical_tool_choice_to_responses(choice: &CanonicalToolChoice) -> Value {
|
||||||
match choice {
|
match choice {
|
||||||
CanonicalToolChoice::Auto => Value::String("auto".to_string()),
|
CanonicalToolChoice::Auto => Value::String("auto".to_string()),
|
||||||
|
|||||||
@@ -1079,6 +1079,33 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.expect("typed canonical claude route should build");
|
.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 =
|
let legacy =
|
||||||
legacy_claude_request_body(&request, provider_api_format, upstream_is_stream);
|
legacy_claude_request_body(&request, provider_api_format, upstream_is_stream);
|
||||||
assert_eq!(
|
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_EXTENSION_NAMESPACE: &str = "openai_responses";
|
||||||
pub(crate) const OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE: &str = "openai_cli";
|
pub(crate) const OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE: &str = "openai_cli";
|
||||||
const AETHER_EXTENSION_NAMESPACE: &str = "aether";
|
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 CLAUDE_TOOL_RESULT_SOURCE_MARKER: &str = "claude_tool_result";
|
||||||
const OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER: &str = "openai_chat_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";
|
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 {
|
Some(vec![CanonicalInstruction {
|
||||||
role: CanonicalRole::System,
|
role: CanonicalRole::System,
|
||||||
text,
|
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 {
|
instructions.push(CanonicalInstruction {
|
||||||
role: CanonicalRole::System,
|
role: CanonicalRole::System,
|
||||||
text: strip_claude_billing_header(text),
|
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(
|
pub(crate) fn claude_messages_to_canonical(
|
||||||
messages: Option<&Value>,
|
messages: Option<&Value>,
|
||||||
) -> Option<Vec<CanonicalMessage>> {
|
) -> Option<Vec<CanonicalMessage>> {
|
||||||
@@ -1174,7 +1214,10 @@ pub(crate) fn claude_block_to_canonical_block(block: &Value) -> Option<Canonical
|
|||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
.map(ToOwned::to_owned),
|
.map(ToOwned::to_owned),
|
||||||
encrypted_content: None,
|
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 {
|
"redacted_thinking" => Some(CanonicalContentBlock::Thinking {
|
||||||
text: String::new(),
|
text: String::new(),
|
||||||
@@ -1183,7 +1226,10 @@ pub(crate) fn claude_block_to_canonical_block(block: &Value) -> Option<Canonical
|
|||||||
.get("data")
|
.get("data")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(ToOwned::to_owned),
|
.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),
|
"image" => claude_media_block_to_canonical(block_object, true),
|
||||||
"document" => claude_media_block_to_canonical(block_object, false),
|
"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)
|
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 {
|
pub(crate) fn is_claude_tool_result(extensions: &BTreeMap<String, Value>) -> bool {
|
||||||
extensions
|
extensions
|
||||||
.get(AETHER_EXTENSION_NAMESPACE)
|
.get(AETHER_EXTENSION_NAMESPACE)
|
||||||
@@ -4108,7 +4170,9 @@ pub(crate) fn canonical_block_to_claude(
|
|||||||
extensions,
|
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));
|
out.extend(namespace_extension_object(extensions, "claude", &out));
|
||||||
Some(Some(Value::Object(out)))
|
Some(Some(Value::Object(out)))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user