llm-bridge-core 0.2.4

Protocol transform library for LLM API translation between Anthropic and OpenAI.
Documentation
//! Responses API → Anthropic request transform.
//!
//! Converts `OpenAI` Responses API requests into Anthropic Messages requests
//! by normalizing to a synthetic Chat Completions request first.

#![allow(clippy::too_many_lines)]

use bytes::Bytes;
use serde::Deserialize;
use serde_json::json;

use super::openai_to_anthropic;
use crate::model::{
    MAX_MESSAGES_COUNT, TransformError, TransformRequest, TransformResponse, validate_json_depth,
};

// ---------------------------------------------------------------------------
// Responses request types
// ---------------------------------------------------------------------------

#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiResponsesRequestBody {
    pub(crate) model: String,
    #[serde(default)]
    pub(crate) input: Option<serde_json::Value>,
    #[serde(default)]
    pub(crate) instructions: Option<String>,
    #[serde(default)]
    pub(crate) tools: Option<Vec<OpenAiResponsesTool>>,
    #[serde(default)]
    pub(crate) tool_choice: Option<serde_json::Value>,
    #[serde(default, rename = "max_output_tokens")]
    pub(crate) max_output_tokens: Option<u64>,
    #[serde(default)]
    pub(crate) temperature: Option<f64>,
    #[serde(default)]
    pub(crate) stream: Option<bool>,
    #[serde(default, rename = "previous_response_id")]
    pub(crate) previous_response_id: Option<String>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct OpenAiResponsesTool {
    #[serde(default, rename = "type")]
    pub(crate) tool_type: String,
    #[serde(default)]
    pub(crate) name: Option<String>,
    #[serde(default)]
    pub(crate) description: Option<String>,
    #[serde(default)]
    pub(crate) parameters: Option<serde_json::Value>,
}

// ---------------------------------------------------------------------------
// Body parsing
// ---------------------------------------------------------------------------

pub(crate) fn parse_openai_responses_request_body(
    bytes: &Bytes,
) -> Result<OpenAiResponsesRequestBody, TransformError> {
    let value: serde_json::Value = serde_json::from_slice(bytes)
        .map_err(|_| TransformError::InvalidFormat("invalid JSON body".into()))?;
    validate_json_depth(&value)?;
    serde_json::from_value(value)
        .map_err(|_| TransformError::InvalidFormat("invalid response structure".into()))
}

// ---------------------------------------------------------------------------
// Responses → Anthropic
// ---------------------------------------------------------------------------

/// Transform an `OpenAI` Responses API request to an Anthropic Messages request.
///
/// # Errors
///
/// Returns `TransformError::InvalidFormat` if the Responses request body cannot
/// be parsed or normalized into a synthetic Chat Completions request.
pub fn responses_to_anthropic(req: &TransformRequest) -> Result<TransformResponse, TransformError> {
    let body: OpenAiResponsesRequestBody = parse_openai_responses_request_body(&req.body)?;

    if body.previous_response_id.is_some() {
        tracing::debug!(
            "lossy downgrade: ignoring Responses API previous_response_id in stateless transform"
        );
    }

    let mut messages = Vec::new();
    if let Some(instructions) = body
        .instructions
        .as_deref()
        .filter(|value| !value.is_empty())
    {
        messages.push(json!({
            "role": "system",
            "content": instructions,
        }));
    }
    let input_messages = responses_input_to_chat_messages(body.input.as_ref())?;

    // Validate total messages array length (prevents unbounded memory allocation).
    if messages.len() + input_messages.len() > MAX_MESSAGES_COUNT {
        return Err(TransformError::BufferLimitExceeded(format!(
            "messages array length {} exceeds maximum of {}",
            messages.len() + input_messages.len(),
            MAX_MESSAGES_COUNT
        )));
    }

    messages.extend(input_messages);

    let mut synthetic_body = serde_json::Map::new();
    synthetic_body.insert("model".to_string(), serde_json::Value::String(body.model));
    synthetic_body.insert("messages".to_string(), serde_json::Value::Array(messages));

    if let Some(max_output_tokens) = body.max_output_tokens {
        synthetic_body.insert(
            "max_tokens".to_string(),
            serde_json::Value::Number(max_output_tokens.into()),
        );
    }
    if let Some(temperature) = body.temperature {
        synthetic_body.insert(
            "temperature".to_string(),
            serde_json::Value::Number(
                serde_json::Number::from_f64(temperature)
                    .map_or(serde_json::Number::from(0), |n| n),
            ),
        );
    }
    if let Some(stream) = body.stream {
        synthetic_body.insert("stream".to_string(), serde_json::Value::Bool(stream));
    }
    if let Some(ref tools) = body.tools {
        synthetic_body.insert(
            "tools".to_string(),
            serde_json::Value::Array(responses_tools_to_chat_tools(tools)?),
        );
    }
    if let Some(ref tool_choice) = body.tool_choice {
        synthetic_body.insert(
            "tool_choice".to_string(),
            normalize_responses_tool_choice(tool_choice)?,
        );
    }

    let synthetic_request = TransformRequest {
        headers: req.headers.clone(),
        path: "/v1/chat/completions".to_string(),
        body: Bytes::from(
            serde_json::to_vec(&serde_json::Value::Object(synthetic_body)).map_err(|e| {
                TransformError::InvalidFormat(format!(
                    "Responses synthetic request serialization failed: {e}"
                ))
            })?,
        ),
    };

    openai_to_anthropic(&synthetic_request)
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

pub(crate) fn responses_tools_to_chat_tools(
    tools: &[OpenAiResponsesTool],
) -> Result<Vec<serde_json::Value>, TransformError> {
    let mut chat_tools = Vec::new();

    for tool in tools {
        if !tool.tool_type.is_empty() && tool.tool_type != "function" {
            tracing::debug!(
                "lossy downgrade: skipping unsupported Responses tool type '{}'",
                tool.tool_type
            );
            continue;
        }

        let name = tool.name.as_ref().ok_or_else(|| {
            TransformError::MissingRequiredField("Responses tools[].name".to_string())
        })?;

        let mut function = serde_json::Map::new();
        function.insert("name".to_string(), serde_json::Value::String(name.clone()));
        if let Some(description) = &tool.description {
            function.insert(
                "description".to_string(),
                serde_json::Value::String(description.clone()),
            );
        }
        if let Some(parameters) = &tool.parameters {
            function.insert("parameters".to_string(), parameters.clone());
        }

        chat_tools.push(json!({
            "type": "function",
            "function": serde_json::Value::Object(function),
        }));
    }

    Ok(chat_tools)
}

pub(crate) fn normalize_responses_tool_choice(
    tool_choice: &serde_json::Value,
) -> Result<serde_json::Value, TransformError> {
    match tool_choice {
        serde_json::Value::String(choice) => match choice.as_str() {
            "auto" | "none" | "required" => Ok(serde_json::Value::String(choice.clone())),
            other => Err(TransformError::InvalidFormat(format!(
                "unsupported Responses tool_choice string: {other}"
            ))),
        },
        serde_json::Value::Object(map) => {
            let choice_type = map
                .get("type")
                .and_then(serde_json::Value::as_str)
                .unwrap_or_default();
            match choice_type {
                "function" => {
                    let name = map
                        .get("name")
                        .and_then(serde_json::Value::as_str)
                        .or_else(|| {
                            map.get("function")
                                .and_then(|value| value.get("name"))
                                .and_then(serde_json::Value::as_str)
                        })
                        .ok_or_else(|| {
                            TransformError::MissingRequiredField(
                                "Responses tool_choice.name".to_string(),
                            )
                        })?;
                    Ok(json!({
                        "type": "function",
                        "function": {
                            "name": name,
                        },
                    }))
                }
                "auto" | "none" | "required" => {
                    Ok(serde_json::Value::String(choice_type.to_string()))
                }
                other => Err(TransformError::InvalidFormat(format!(
                    "unsupported Responses tool_choice object type: {other}"
                ))),
            }
        }
        other => Err(TransformError::InvalidFormat(format!(
            "unsupported Responses tool_choice type: {other:?}"
        ))),
    }
}

pub(crate) fn responses_input_to_chat_messages(
    input: Option<&serde_json::Value>,
) -> Result<Vec<serde_json::Value>, TransformError> {
    match input {
        None | Some(serde_json::Value::Null) => Ok(Vec::new()),
        Some(serde_json::Value::String(text)) => Ok(vec![json!({
            "role": "user",
            "content": text,
        })]),
        Some(serde_json::Value::Array(items)) => {
            let mut messages = Vec::new();
            for item in items {
                messages.extend(responses_input_item_to_chat_messages(item)?);
            }
            Ok(messages)
        }
        Some(serde_json::Value::Object(obj)) => {
            let item: serde_json::Value = serde_json::Value::Object(obj.clone());
            responses_input_item_to_chat_messages(&item)
        }
        Some(other) => Err(TransformError::InvalidFormat(format!(
            "unsupported Responses input type: {other:?}"
        ))),
    }
}

pub(crate) fn responses_input_item_to_chat_messages(
    item: &serde_json::Value,
) -> Result<Vec<serde_json::Value>, TransformError> {
    let item_type = item.get("type").and_then(serde_json::Value::as_str);

    match item_type {
        Some("message") | None if item.get("role").is_some() => {
            let role = item
                .get("role")
                .and_then(serde_json::Value::as_str)
                .ok_or_else(|| {
                    TransformError::MissingRequiredField("Responses message.role".to_string())
                })?;
            let chat_role = match role {
                "developer" => "system",
                "system" | "user" | "assistant" | "tool" => role,
                other => {
                    tracing::debug!(
                        "lossy downgrade: mapping unsupported Responses role '{}' to 'user'",
                        other
                    );
                    "user"
                }
            };
            Ok(vec![json!({
                "role": chat_role,
                "content": responses_content_to_text(item.get("content")),
            })])
        }
        Some("function_call_output") => {
            let call_id = item
                .get("call_id")
                .and_then(serde_json::Value::as_str)
                .ok_or_else(|| {
                    TransformError::MissingRequiredField(
                        "Responses function_call_output.call_id".to_string(),
                    )
                })?;
            Ok(vec![json!({
                "role": "tool",
                "tool_call_id": call_id,
                "content": responses_content_to_text(item.get("output")),
            })])
        }
        Some("function_call") => {
            let call_id = item
                .get("call_id")
                .and_then(serde_json::Value::as_str)
                .ok_or_else(|| {
                    TransformError::MissingRequiredField(
                        "Responses function_call.call_id".to_string(),
                    )
                })?;
            let name = item
                .get("name")
                .and_then(serde_json::Value::as_str)
                .ok_or_else(|| {
                    TransformError::MissingRequiredField("Responses function_call.name".to_string())
                })?;
            let arguments = item
                .get("arguments")
                .and_then(serde_json::Value::as_str)
                .unwrap_or("");
            Ok(vec![json!({
                "role": "assistant",
                "content": "",
                "tool_calls": [{
                    "id": call_id,
                    "type": "function",
                    "function": {
                        "name": name,
                        "arguments": arguments,
                    },
                }],
            })])
        }
        Some("reasoning") => {
            tracing::debug!("lossy downgrade: skipping standalone Responses reasoning input item");
            Ok(Vec::new())
        }
        Some(other) => {
            tracing::debug!(
                "lossy downgrade: skipping unsupported Responses input item type '{}'",
                other
            );
            Ok(Vec::new())
        }
        None => Ok(Vec::new()),
    }
}

pub(crate) fn responses_content_to_text(content: Option<&serde_json::Value>) -> String {
    match content {
        Some(serde_json::Value::String(text)) => text.clone(),
        Some(serde_json::Value::Array(parts)) => parts
            .iter()
            .filter_map(response_content_part_to_text)
            .collect::<Vec<_>>()
            .join("\n"),
        Some(serde_json::Value::Object(obj)) => {
            let part: serde_json::Value = serde_json::Value::Object(obj.clone());
            response_content_part_to_text(&part).unwrap_or_default()
        }
        _ => String::new(),
    }
}

pub(crate) fn response_content_part_to_text(part: &serde_json::Value) -> Option<String> {
    match part {
        serde_json::Value::String(text) => Some(text.clone()),
        serde_json::Value::Object(map) => map
            .get("text")
            .and_then(serde_json::Value::as_str)
            .map(str::to_string),
        _ => None,
    }
}